feat(web): theme import/export, more themable colors, hideable local/ical calendars
Theme: - Export/import themes via UI as <date>_<time>.theme (JSON, readable keys, link to THEME.md). Import is partial-aware: only present params are written (server accepts partial); prompts before importing files with unknown params. - Dynamic favicon + theme-color tinted to the primary colour on load/save. - New themable colours: general hover-highlight, day hover/selected/bg, today background, plus two unified sidebar action-icon colours (inactive/active) covering bell, hide, delete and read-only icons. - All new colours are per-setting syncable; documented in THEME.md. UX: - Styled confirm dialog (#modal-confirm) replaces window.confirm() for calendar delete and account disconnect. - Birthday/local calendars and iCal subscriptions can now be hidden from the sidebar via Settings (new sidebar_hidden column + hide toggle). Backend: additive nullable columns + idempotent migrations for user_settings colours and local_calendars/ical_subscriptions.sidebar_hidden. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
66
THEME.md
Normal file
66
THEME.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Theme parameters (Web)
|
||||||
|
|
||||||
|
Calendarr's web client lets you customise the colour theme under
|
||||||
|
**Settings → Darstellung → Farben**. Every colour has its own sync toggle (share
|
||||||
|
it across your devices or keep it device-local — see
|
||||||
|
[backend/SETTINGS_SYNC.md](backend/SETTINGS_SYNC.md)).
|
||||||
|
|
||||||
|
You can also **export** the current theme to a `<date>_<time>.theme` file and
|
||||||
|
**import** one later. A `.theme` file is plain JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_format": "calendarr-theme",
|
||||||
|
"_version": 1,
|
||||||
|
"_docs": "https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md",
|
||||||
|
"exported_at": "2026-07-20T14:33:00.000Z",
|
||||||
|
"settings": {
|
||||||
|
"primary_color": "#4285F4",
|
||||||
|
"hover_highlight_color": "#2A2A38",
|
||||||
|
"...": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A theme may be **partial** — delete any keys you don't want and only the
|
||||||
|
remaining ones are applied. Importing writes **only the parameters present in
|
||||||
|
the file** (keys whose sync toggle is on are also pushed to the server; the rest
|
||||||
|
update this browser only); omitted parameters are left untouched. If the file
|
||||||
|
contains parameters this version doesn't recognise, you're asked whether to
|
||||||
|
import the rest anyway.
|
||||||
|
|
||||||
|
## Colour parameters
|
||||||
|
|
||||||
|
Source of truth for the defaults: `DEFAULT_COLORS` in
|
||||||
|
[frontend/js/settings-sync.js](frontend/js/settings-sync.js). Each value is a
|
||||||
|
`#RRGGBB` hex string. Where a default is listed as "derived", leaving the value
|
||||||
|
untouched reproduces the previous automatic look; setting it overrides that.
|
||||||
|
|
||||||
|
| Key | What it colours | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `primary_color` | Primary/brand colour — buttons, links, active states, and the browser favicon/tab colour | `#4285F4` |
|
||||||
|
| `accent_color` | Accent — danger actions, the "now" line, reminders | `#EA4335` |
|
||||||
|
| `today_color` | "Today" accent: the day-number circle and today's labels | `#4285F4` |
|
||||||
|
| `text_color` | Base text colour (secondary/tertiary text is derived from it) | `#FFFFFF` |
|
||||||
|
| `bg_color` | App background | `#000000` |
|
||||||
|
| `surface_color` | Sidebar / top bar / card surfaces (derived from `bg_color` when unset) | `#1A1A1A` |
|
||||||
|
| `line_color` | Borders and grid lines | `#3A3A52` |
|
||||||
|
| `month_divider_color` | The line marking a month change in the scrolling month view | `#7090C0` |
|
||||||
|
| `month_label_color` | The month abbreviation shown at a month change | `#7090C0` |
|
||||||
|
| `hover_highlight_color` | General interactive hover — buttons, menu items, list rows | `#2A2A38` (= derived hover) |
|
||||||
|
| `icon_inactive_color` | Sidebar action icons (notification bell *off*, hide/eye, delete/trash, "not editable") in their resting / off / not-hovered state | `#9090AA` (= secondary text) |
|
||||||
|
| `icon_active_color` | The same sidebar action icons when hovered, pressed, or *on* (e.g. notification bell enabled) | `#E8E8F0` (= primary text) |
|
||||||
|
| `day_hover_color` | Hover background over a calendar day (month / week / quarter / agenda / mini-calendar / date picker) | `#2A2A38` (= derived hover) |
|
||||||
|
| `day_selected_color` | The selected day — applied as a subtle tint of this colour | `#4285F4` (= primary) |
|
||||||
|
| `day_bg_color` | Normal (unselected, non-today) day background. Defaults to the app background so days look transparent | `#000000` |
|
||||||
|
| `today_bg_color` | Today's day-cell background — applied as a subtle tint of this colour | `#4285F4` (= today accent) |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `day_selected_color` and `today_bg_color` are applied as a low-opacity tint of
|
||||||
|
the chosen colour (so content stays readable). The colour you pick is the base
|
||||||
|
hue; the swatch shows the full colour.
|
||||||
|
- `day_bg_color` defaults to the app background. If you set a custom
|
||||||
|
`bg_color`, also set `day_bg_color` to match if you want fully transparent days.
|
||||||
|
- The other clients (iOS / Android) currently ignore the fine-grained element
|
||||||
|
colours; they are stored and synced by the server but only the web client
|
||||||
|
renders them.
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# Settings sync contract (Web / iOS / Android)
|
# Settings sync contract (Web / iOS / Android)
|
||||||
|
|
||||||
|
> For a human-facing description of each theme colour parameter (and the
|
||||||
|
> `.theme` import/export format), see [../THEME.md](../THEME.md).
|
||||||
|
|
||||||
Per-setting, cross-device synchronisation of user settings. The **server is the
|
Per-setting, cross-device synchronisation of user settings. The **server is the
|
||||||
sole authority** for *which* settings sync; clients must not duplicate that logic.
|
sole authority** for *which* settings sync; clients must not duplicate that logic.
|
||||||
This document is the shared contract all three clients implement identically.
|
This document is the shared contract all three clients implement identically.
|
||||||
|
|||||||
@@ -240,6 +240,29 @@ def _migrate():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Fine-grained web element theme colours (all optional overrides).
|
||||||
|
for col in (
|
||||||
|
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
|
||||||
|
"day_hover_color", "day_selected_color",
|
||||||
|
"day_bg_color", "today_bg_color",
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
conn.execute(text(f"ALTER TABLE user_settings ADD COLUMN {col} VARCHAR(7)"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added %s to user_settings", col)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Allow hiding local (incl. birthday) calendars and iCal subscriptions
|
||||||
|
# from the sidebar, matching caldav/google/ha.
|
||||||
|
for tbl in ("local_calendars", "ical_subscriptions"):
|
||||||
|
try:
|
||||||
|
conn.execute(text(f"ALTER TABLE {tbl} ADD COLUMN sidebar_hidden BOOLEAN DEFAULT 0"))
|
||||||
|
conn.commit()
|
||||||
|
logging.info("Migration: added sidebar_hidden to %s", tbl)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# CalDAV publishing of local calendars (opt-in, secret token URL).
|
# CalDAV publishing of local calendars (opt-in, secret token URL).
|
||||||
for col, ddl in (
|
for col, ddl in (
|
||||||
("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"),
|
("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"),
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ class UserSettings(Base):
|
|||||||
# Surface/sidebar/topbar colour (web sidebar + top bar, iOS top bar).
|
# Surface/sidebar/topbar colour (web sidebar + top bar, iOS top bar).
|
||||||
# NULL = derive from bg_color. Device-local by default (not synced).
|
# NULL = derive from bg_color. Device-local by default (not synced).
|
||||||
surface_color = Column(String(7), nullable=True)
|
surface_color = Column(String(7), nullable=True)
|
||||||
|
# Fine-grained web element colours (all optional overrides; NULL = derive the
|
||||||
|
# previous default). See frontend THEME.md for what each one paints.
|
||||||
|
hover_highlight_color = Column(String(7), nullable=True) # general interactive hover (buttons, rows)
|
||||||
|
icon_inactive_color = Column(String(7), nullable=True) # sidebar action icons: resting/off state
|
||||||
|
icon_active_color = Column(String(7), nullable=True) # sidebar action icons: hovered/on state
|
||||||
|
day_hover_color = Column(String(7), nullable=True) # calendar day-cell hover
|
||||||
|
day_selected_color = Column(String(7), nullable=True) # selected day background
|
||||||
|
day_bg_color = Column(String(7), nullable=True) # normal day background
|
||||||
|
today_bg_color = Column(String(7), nullable=True) # today's day-cell background
|
||||||
# How this user's private events appear to other group members:
|
# How this user's private events appear to other group members:
|
||||||
# 'hidden' = invisible, 'busy' = anonymous busy block (default).
|
# 'hidden' = invisible, 'busy' = anonymous busy block (default).
|
||||||
private_event_visibility = Column(String(10), default="busy")
|
private_event_visibility = Column(String(10), default="busy")
|
||||||
@@ -158,6 +167,8 @@ class LocalCalendar(Base):
|
|||||||
name = Column(String(100), nullable=False)
|
name = Column(String(100), nullable=False)
|
||||||
color = Column(String(7), default="#34a853")
|
color = Column(String(7), default="#34a853")
|
||||||
enabled = Column(Boolean, default=True)
|
enabled = Column(Boolean, default=True)
|
||||||
|
# Hidden from the sidebar calendar list (still owned/kept; just not shown).
|
||||||
|
sidebar_hidden = Column(Boolean, default=False, nullable=False)
|
||||||
# Whether events of this calendar generate reminders/notifications on clients.
|
# Whether events of this calendar generate reminders/notifications on clients.
|
||||||
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
||||||
# CalDAV publishing (opt-in): expose this calendar as a two-way CalDAV
|
# CalDAV publishing (opt-in): expose this calendar as a two-way CalDAV
|
||||||
@@ -221,6 +232,8 @@ class ICalSubscription(Base):
|
|||||||
url = Column(String(1000), nullable=False)
|
url = Column(String(1000), nullable=False)
|
||||||
color = Column(String(7), default="#46bdc6")
|
color = Column(String(7), default="#46bdc6")
|
||||||
enabled = Column(Boolean, default=True)
|
enabled = Column(Boolean, default=True)
|
||||||
|
# Hidden from the sidebar calendar list (still subscribed; just not shown).
|
||||||
|
sidebar_hidden = Column(Boolean, default=False, nullable=False)
|
||||||
# Whether events of this subscription generate reminders/notifications on clients.
|
# Whether events of this subscription generate reminders/notifications on clients.
|
||||||
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
||||||
refresh_minutes = Column(Integer, default=60)
|
refresh_minutes = Column(Integer, default=60)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class SubscriptionUpdate(BaseModel):
|
|||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
enabled: Optional[bool] = None
|
enabled: Optional[bool] = None
|
||||||
|
sidebar_hidden: Optional[bool] = None
|
||||||
refresh_minutes: Optional[int] = None
|
refresh_minutes: Optional[int] = None
|
||||||
reminders_enabled: Optional[bool] = None
|
reminders_enabled: Optional[bool] = None
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ def _sub_dict(sub: models.ICalSubscription) -> dict:
|
|||||||
"url": sub.url,
|
"url": sub.url,
|
||||||
"color": sub.color,
|
"color": sub.color,
|
||||||
"enabled": sub.enabled,
|
"enabled": sub.enabled,
|
||||||
|
"sidebar_hidden": bool(sub.sidebar_hidden),
|
||||||
"reminders_enabled": bool(sub.reminders_enabled),
|
"reminders_enabled": bool(sub.reminders_enabled),
|
||||||
"refresh_minutes": sub.refresh_minutes,
|
"refresh_minutes": sub.refresh_minutes,
|
||||||
"last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None,
|
"last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None,
|
||||||
@@ -273,6 +275,8 @@ def update_subscription(
|
|||||||
sub.color = data.color
|
sub.color = data.color
|
||||||
if data.enabled is not None:
|
if data.enabled is not None:
|
||||||
sub.enabled = data.enabled
|
sub.enabled = data.enabled
|
||||||
|
if data.sidebar_hidden is not None:
|
||||||
|
sub.sidebar_hidden = data.sidebar_hidden
|
||||||
if data.refresh_minutes is not None:
|
if data.refresh_minutes is not None:
|
||||||
sub.refresh_minutes = data.refresh_minutes
|
sub.refresh_minutes = data.refresh_minutes
|
||||||
if data.reminders_enabled is not None:
|
if data.reminders_enabled is not None:
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class CalendarUpdate(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
color: Optional[str] = None
|
color: Optional[str] = None
|
||||||
enabled: Optional[bool] = None
|
enabled: Optional[bool] = None
|
||||||
|
sidebar_hidden: Optional[bool] = None
|
||||||
reminders_enabled: Optional[bool] = None
|
reminders_enabled: Optional[bool] = None
|
||||||
caldav_published: Optional[bool] = None
|
caldav_published: Optional[bool] = None
|
||||||
is_birthday: Optional[bool] = None
|
is_birthday: Optional[bool] = None
|
||||||
@@ -88,6 +89,7 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
|||||||
# A recipient's own colour for a shared calendar wins over the owner's.
|
# A recipient's own colour for a shared calendar wins over the owner's.
|
||||||
"color": color_override or cal.color,
|
"color": color_override or cal.color,
|
||||||
"enabled": cal.enabled,
|
"enabled": cal.enabled,
|
||||||
|
"sidebar_hidden": bool(cal.sidebar_hidden),
|
||||||
"reminders_enabled": bool(cal.reminders_enabled),
|
"reminders_enabled": bool(cal.reminders_enabled),
|
||||||
"caldav_published": bool(cal.caldav_published),
|
"caldav_published": bool(cal.caldav_published),
|
||||||
"is_birthday": bool(cal.is_birthday),
|
"is_birthday": bool(cal.is_birthday),
|
||||||
@@ -265,6 +267,8 @@ def update_calendar(
|
|||||||
cal.color = data.color
|
cal.color = data.color
|
||||||
if data.enabled is not None:
|
if data.enabled is not None:
|
||||||
cal.enabled = data.enabled
|
cal.enabled = data.enabled
|
||||||
|
if data.sidebar_hidden is not None:
|
||||||
|
cal.sidebar_hidden = data.sidebar_hidden
|
||||||
if data.reminders_enabled is not None:
|
if data.reminders_enabled is not None:
|
||||||
cal.reminders_enabled = data.reminders_enabled
|
cal.reminders_enabled = data.reminders_enabled
|
||||||
if data.is_birthday is not None:
|
if data.is_birthday is not None:
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ DEFAULT_SYNC = {
|
|||||||
"cache_months": False,
|
"cache_months": False,
|
||||||
"month_view_paged": False,
|
"month_view_paged": False,
|
||||||
"surface_color": False,
|
"surface_color": False,
|
||||||
|
"hover_highlight_color": True,
|
||||||
|
"icon_inactive_color": True,
|
||||||
|
"icon_active_color": True,
|
||||||
|
"day_hover_color": True,
|
||||||
|
"day_selected_color": True,
|
||||||
|
"day_bg_color": True,
|
||||||
|
"today_bg_color": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +79,13 @@ class SettingsUpdate(BaseModel):
|
|||||||
line_color: Optional[str] = None
|
line_color: Optional[str] = None
|
||||||
bg_color: Optional[str] = None
|
bg_color: Optional[str] = None
|
||||||
surface_color: Optional[str] = None
|
surface_color: Optional[str] = None
|
||||||
|
hover_highlight_color: Optional[str] = None
|
||||||
|
icon_inactive_color: Optional[str] = None
|
||||||
|
icon_active_color: Optional[str] = None
|
||||||
|
day_hover_color: Optional[str] = None
|
||||||
|
day_selected_color: Optional[str] = None
|
||||||
|
day_bg_color: Optional[str] = None
|
||||||
|
today_bg_color: Optional[str] = None
|
||||||
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
|
||||||
@@ -100,6 +114,13 @@ def _settings_dict(s: models.UserSettings) -> dict:
|
|||||||
"line_color": s.line_color,
|
"line_color": s.line_color,
|
||||||
"bg_color": s.bg_color,
|
"bg_color": s.bg_color,
|
||||||
"surface_color": s.surface_color,
|
"surface_color": s.surface_color,
|
||||||
|
"hover_highlight_color": s.hover_highlight_color,
|
||||||
|
"icon_inactive_color": s.icon_inactive_color,
|
||||||
|
"icon_active_color": s.icon_active_color,
|
||||||
|
"day_hover_color": s.day_hover_color,
|
||||||
|
"day_selected_color": s.day_selected_color,
|
||||||
|
"day_bg_color": s.day_bg_color,
|
||||||
|
"today_bg_color": s.today_bg_color,
|
||||||
"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,
|
||||||
@@ -165,7 +186,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", "surface_color", "group_visible_calendar_id", "default_reminder_minutes", "default_event_duration_minutes", "share_calendar_icon"}
|
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "surface_color", "hover_highlight_color", "icon_inactive_color", "icon_active_color", "day_hover_color", "day_selected_color", "day_bg_color", "today_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)
|
||||||
|
|
||||||
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
|
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
|
||||||
|
|||||||
@@ -23,6 +23,17 @@
|
|||||||
--border-light: #242438;
|
--border-light: #242438;
|
||||||
--scrollbar: #30303c;
|
--scrollbar: #30303c;
|
||||||
|
|
||||||
|
/* Fine-grained element colours (customisable via Settings → Farben).
|
||||||
|
Defaults reference the derived values so the look is unchanged until the
|
||||||
|
user overrides them; applyTheme() writes concrete overrides on top. */
|
||||||
|
--hover-highlight: var(--bg-hover); /* general interactive hover */
|
||||||
|
--icon-inactive-color: var(--text-2); /* sidebar action icons: resting/off/not-hovered */
|
||||||
|
--icon-active-color: var(--text-1); /* sidebar action icons: hovered / on / pressed */
|
||||||
|
--day-hover-color: var(--bg-hover); /* calendar day-cell hover */
|
||||||
|
--day-selected-base: var(--primary); /* selected day (tinted via color-mix) */
|
||||||
|
--day-bg: transparent; /* normal day background */
|
||||||
|
--today-bg-base: var(--today-color); /* today's day (tinted via color-mix) */
|
||||||
|
|
||||||
--topbar-h: 64px;
|
--topbar-h: 64px;
|
||||||
--sidebar-w: 256px;
|
--sidebar-w: 256px;
|
||||||
--shadow: 0 2px 12px rgba(0,0,0,.45);
|
--shadow: 0 2px 12px rgba(0,0,0,.45);
|
||||||
@@ -201,7 +212,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
transform .1s ease;
|
transform .1s ease;
|
||||||
}
|
}
|
||||||
.icon-btn svg { width: 20px; height: 20px; fill: currentColor; }
|
.icon-btn svg { width: 20px; height: 20px; fill: currentColor; }
|
||||||
.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
.icon-btn:hover { background: var(--hover-highlight); color: var(--text-1); }
|
||||||
.icon-btn:active { transform: scale(.92); }
|
.icon-btn:active { transform: scale(.92); }
|
||||||
.icon-btn:focus-visible {
|
.icon-btn:focus-visible {
|
||||||
outline: 2px solid var(--primary);
|
outline: 2px solid var(--primary);
|
||||||
@@ -310,7 +321,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 9px 10px; border-radius: 8px; cursor: pointer;
|
padding: 9px 10px; border-radius: 8px; cursor: pointer;
|
||||||
}
|
}
|
||||||
.cal-select-option:hover { background: var(--bg-hover); }
|
.cal-select-option:hover { background: var(--hover-highlight); }
|
||||||
.cal-select-option.selected { background: var(--primary-dim); }
|
.cal-select-option.selected { background: var(--primary-dim); }
|
||||||
|
|
||||||
/* ── Date/time input dark mode ──────────────────────────── */
|
/* ── Date/time input dark mode ──────────────────────────── */
|
||||||
@@ -481,7 +492,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
transition: background var(--transition), color var(--transition);
|
transition: background var(--transition), color var(--transition);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
.view-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
.view-btn:hover { background: var(--hover-highlight); color: var(--text-1); }
|
||||||
.view-btn.active { background: var(--primary-dim); color: var(--primary); }
|
.view-btn.active { background: var(--primary-dim); color: var(--primary); }
|
||||||
|
|
||||||
.user-menu-wrapper { position: relative; }
|
.user-menu-wrapper { position: relative; }
|
||||||
@@ -510,7 +521,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
background: none; color: var(--text-2); font-size: 13px;
|
background: none; color: var(--text-2); font-size: 13px;
|
||||||
cursor: pointer; transition: background var(--transition);
|
cursor: pointer; transition: background var(--transition);
|
||||||
}
|
}
|
||||||
.dropdown-item:hover { background: var(--bg-hover); color: var(--text-1); }
|
.dropdown-item:hover { background: var(--hover-highlight); color: var(--text-1); }
|
||||||
.dropdown-item svg { flex-shrink: 0;
|
.dropdown-item svg { flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,7 +566,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
.mini-cal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||||
.mini-title { font-size: 13px; font-weight: 500; color: var(--text-1); }
|
.mini-title { font-size: 13px; font-weight: 500; color: var(--text-1); }
|
||||||
.mini-btn { width: 28px; height: 28px; font-size: 18px; color: var(--text-2); }
|
.mini-btn { width: 28px; height: 28px; font-size: 18px; color: var(--text-2); }
|
||||||
.mini-btn:hover { color: var(--text-1); background: var(--bg-hover); }
|
.mini-btn:hover { color: var(--text-1); background: var(--hover-highlight); }
|
||||||
.mini-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; }
|
.mini-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; }
|
||||||
.mini-dow { font-size: 11px; color: var(--text-3); padding: 2px 0; font-weight: 500; }
|
.mini-dow { font-size: 11px; color: var(--text-3); padding: 2px 0; font-weight: 500; }
|
||||||
.mini-cal-days { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; }
|
.mini-cal-days { display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; }
|
||||||
@@ -566,13 +577,13 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
margin: 1px auto; position: relative;
|
margin: 1px auto; position: relative;
|
||||||
}
|
}
|
||||||
.mini-day:hover { background: var(--bg-hover); color: var(--text-1); }
|
.mini-day:hover { background: var(--day-hover-color); color: var(--text-1); }
|
||||||
.mini-day.other-month { color: var(--text-3); }
|
.mini-day.other-month { color: var(--text-3); }
|
||||||
.mini-day.today {
|
.mini-day.today {
|
||||||
background: var(--today-color);
|
background: var(--today-color);
|
||||||
color: #fff; font-weight: 700;
|
color: #fff; font-weight: 700;
|
||||||
}
|
}
|
||||||
.mini-day.selected:not(.today) { background: var(--primary-dim); color: var(--primary); font-weight: 600; }
|
.mini-day.selected:not(.today) { background: color-mix(in srgb, var(--day-selected-base) 15%, transparent); color: var(--primary); font-weight: 600; }
|
||||||
.mini-day.has-events::after {
|
.mini-day.has-events::after {
|
||||||
content: ''; position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%);
|
content: ''; position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%);
|
||||||
width: 4px; height: 4px; border-radius: 50%; background: var(--primary);
|
width: 4px; height: 4px; border-radius: 50%; background: var(--primary);
|
||||||
@@ -599,7 +610,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
text-align: left; font-size: 13px; color: var(--text-1);
|
text-align: left; font-size: 13px; color: var(--text-1);
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
}
|
}
|
||||||
.add-cal-dropdown button:hover { background: var(--bg-hover); }
|
.add-cal-dropdown button:hover { background: var(--hover-highlight); }
|
||||||
.cal-item {
|
.cal-item {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 6px 16px; cursor: pointer;
|
padding: 6px 16px; cursor: pointer;
|
||||||
@@ -607,7 +618,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
border-radius: 0 20px 20px 0;
|
border-radius: 0 20px 20px 0;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
.cal-item:hover { background: var(--bg-hover); }
|
.cal-item:hover { background: var(--hover-highlight); }
|
||||||
.cal-item-dot {
|
.cal-item-dot {
|
||||||
width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; cursor: pointer;
|
width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -624,11 +635,16 @@ a { color: var(--primary); text-decoration: 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; }
|
||||||
/* Fixed min-height so 28px mini-btns never change row height when they appear. */
|
/* Fixed min-height so 28px mini-btns never change row height when they appear. */
|
||||||
.cal-item { position: relative; min-height: 40px; }
|
.cal-item { position: relative; min-height: 40px; }
|
||||||
.cal-item-bell { display: none; flex-shrink: 0; }
|
/* Sidebar action icons (bell / hide / delete / read-only flag) share two theme
|
||||||
|
colours: "inactive" (resting/off) and "active" (hovered/on/pressed). */
|
||||||
|
.cal-item-bell { display: none; flex-shrink: 0; color: var(--icon-active-color); } /* bell shown = reminders on = active */
|
||||||
.cal-item:hover .cal-item-remove,
|
.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; opacity: .55; color: var(--text-3); }
|
.cal-item-bell.off { display: inline-flex; opacity: .55; color: var(--icon-inactive-color); }
|
||||||
.cal-item:hover .cal-item-bell.off { opacity: 1; color: inherit; }
|
.cal-item:hover .cal-item-bell.off { opacity: 1; color: var(--icon-inactive-color); }
|
||||||
|
.cal-item-bell:hover, .cal-item-bell.off:hover { color: var(--icon-active-color); }
|
||||||
|
.cal-item-remove { color: var(--icon-inactive-color); }
|
||||||
|
.cal-item-remove:hover { color: var(--icon-active-color); }
|
||||||
|
|
||||||
/* ── 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; }
|
||||||
@@ -661,11 +677,12 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
flex: 1; border-right: 1px solid var(--border);
|
flex: 1; border-right: 1px solid var(--border);
|
||||||
cursor: pointer; transition: background var(--transition);
|
cursor: pointer; transition: background var(--transition);
|
||||||
padding: 4px 4px 0; min-width: 0;
|
padding: 4px 4px 0; min-width: 0;
|
||||||
|
background: var(--day-bg);
|
||||||
}
|
}
|
||||||
.month-col:last-child { border-right: none; }
|
.month-col:last-child { border-right: none; }
|
||||||
.month-col:hover { background: var(--bg-hover); }
|
.month-col:hover { background: var(--day-hover-color); }
|
||||||
.month-col.today { background: color-mix(in srgb, var(--today-color) 10%, transparent); }
|
.month-col.today { background: color-mix(in srgb, var(--today-bg-base) 10%, transparent); }
|
||||||
.month-col.month-selected { background: var(--primary-dim); }
|
.month-col.month-selected { background: color-mix(in srgb, var(--day-selected-base) 15%, transparent); }
|
||||||
.month-col.month-selected .cell-day { border: 2px solid var(--primary); color: var(--primary); font-weight: 700; }
|
.month-col.month-selected .cell-day { border: 2px solid var(--primary); color: var(--primary); font-weight: 700; }
|
||||||
.month-col.month-selected .cell-day.today { background: var(--today-color); color: #fff; border: none; }
|
.month-col.month-selected .cell-day.today { background: var(--today-color); color: #fff; border: none; }
|
||||||
.month-col.other-month .cell-day { color: var(--text-3); }
|
.month-col.other-month .cell-day { color: var(--text-3); }
|
||||||
@@ -786,7 +803,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
padding: 4px 12px; cursor: pointer;
|
padding: 4px 12px; cursor: pointer;
|
||||||
border-radius: 6px; margin: 0 4px;
|
border-radius: 6px; margin: 0 4px;
|
||||||
}
|
}
|
||||||
.mop-row:hover { background: var(--bg-hover); }
|
.mop-row:hover { background: var(--hover-highlight); }
|
||||||
.mop-dot {
|
.mop-dot {
|
||||||
width: 8px; height: 8px; border-radius: 50%;
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -847,7 +864,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
border-left: 1px solid var(--border); cursor: pointer;
|
border-left: 1px solid var(--border); cursor: pointer;
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
}
|
}
|
||||||
.week-day-header:hover { background: var(--bg-hover); }
|
.week-day-header:hover { background: var(--day-hover-color); }
|
||||||
.week-day-header .day-name {
|
.week-day-header .day-name {
|
||||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||||
letter-spacing: .5px; color: var(--text-2);
|
letter-spacing: .5px; color: var(--text-2);
|
||||||
@@ -999,7 +1016,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
.qtr-cell:hover { background: var(--bg-hover); }
|
.qtr-cell:hover { background: var(--day-hover-color); }
|
||||||
.qtr-cell.today .qtr-day-num {
|
.qtr-cell.today .qtr-day-num {
|
||||||
background: var(--today-color, var(--primary));
|
background: var(--today-color, var(--primary));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -1062,7 +1079,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
cursor: pointer; transition: background var(--transition);
|
cursor: pointer; transition: background var(--transition);
|
||||||
margin-left: 54px; margin-bottom: 4px;
|
margin-left: 54px; margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.agenda-event:hover { background: var(--bg-hover); }
|
.agenda-event:hover { background: var(--day-hover-color); }
|
||||||
.agenda-event.past { opacity: .45; }
|
.agenda-event.past { opacity: .45; }
|
||||||
.agenda-ev-color { width: 10px; height: 10px; border-radius: 50%; margin-top: 4px; flex-shrink: 0; }
|
.agenda-ev-color { width: 10px; height: 10px; border-radius: 50%; margin-top: 4px; flex-shrink: 0; }
|
||||||
.agenda-ev-info { flex: 1; }
|
.agenda-ev-info { flex: 1; }
|
||||||
@@ -1114,7 +1131,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
transition: background var(--transition), color var(--transition);
|
transition: background var(--transition), color var(--transition);
|
||||||
}
|
}
|
||||||
.rec-day-btn:hover { background: var(--bg-hover); }
|
.rec-day-btn:hover { background: var(--hover-highlight); }
|
||||||
.rec-day-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); }
|
.rec-day-btn.active { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||||
|
|
||||||
/* ── Day Context Menu ──────────────────────────────────── */
|
/* ── Day Context Menu ──────────────────────────────────── */
|
||||||
@@ -1127,7 +1144,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.ctx-item {
|
.ctx-item {
|
||||||
padding: 8px 16px; font-size: 13px; color: var(--text-1); cursor: pointer;
|
padding: 8px 16px; font-size: 13px; color: var(--text-1); cursor: pointer;
|
||||||
}
|
}
|
||||||
.ctx-item:hover { background: var(--bg-hover); }
|
.ctx-item:hover { background: var(--hover-highlight); }
|
||||||
|
|
||||||
/* ── Event Popup ──────────────────────────────────────────
|
/* ── Event Popup ──────────────────────────────────────────
|
||||||
Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
|
Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
|
||||||
@@ -1251,7 +1268,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
}
|
}
|
||||||
.popup-copy-item:hover { background: var(--bg-hover); }
|
.popup-copy-item:hover { background: var(--hover-highlight); }
|
||||||
.popup-copy-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
.popup-copy-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||||
.popup-copy-edit-toggle {
|
.popup-copy-edit-toggle {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
@@ -1300,7 +1317,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
transition: background .15s, color .15s;
|
transition: background .15s, color .15s;
|
||||||
}
|
}
|
||||||
.settings-nav-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
.settings-nav-btn:hover { background: var(--hover-highlight); color: var(--text-1); }
|
||||||
.settings-nav-btn.active { background: var(--primary-dim); color: var(--primary); font-weight: 600; }
|
.settings-nav-btn.active { background: var(--primary-dim); color: var(--primary); font-weight: 600; }
|
||||||
.settings-panels {
|
.settings-panels {
|
||||||
flex: 1; overflow-y: auto; padding: 24px 28px;
|
flex: 1; overflow-y: auto; padding: 24px 28px;
|
||||||
@@ -1338,7 +1355,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
transition: background var(--transition), color var(--transition);
|
transition: background var(--transition), color var(--transition);
|
||||||
}
|
}
|
||||||
.contrast-btn:last-child { border-right: none; }
|
.contrast-btn:last-child { border-right: none; }
|
||||||
.contrast-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
.contrast-btn:hover { background: var(--hover-highlight); color: var(--text-1); }
|
||||||
.contrast-btn.active { background: var(--primary); color: #fff; }
|
.contrast-btn.active { background: var(--primary); color: #fff; }
|
||||||
.contrast-btn span { font-size: 18px; font-weight: 700; line-height: 1; }
|
.contrast-btn span { font-size: 18px; font-weight: 700; line-height: 1; }
|
||||||
.contrast-btn.active span { color: #fff !important; }
|
.contrast-btn.active span { color: #fff !important; }
|
||||||
@@ -1365,6 +1382,14 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.sync-global-row .panel-desc { margin: 0; }
|
.sync-global-row .panel-desc { margin: 0; }
|
||||||
|
|
||||||
.settings-table { display: flex; flex-direction: column; }
|
.settings-table { display: flex; flex-direction: column; }
|
||||||
|
.theme-io-row { display: flex; align-items: center; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
|
||||||
|
.theme-io-help {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
background: var(--bg-hover); color: var(--text-2);
|
||||||
|
font-size: 12px; font-weight: 700; text-decoration: none;
|
||||||
|
}
|
||||||
|
.theme-io-help:hover { background: var(--hover-highlight); color: var(--text-1); }
|
||||||
.settings-table-section {
|
.settings-table-section {
|
||||||
font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
||||||
color: var(--text-3); margin: 18px 0 6px; padding: 0 2px;
|
color: var(--text-3); margin: 18px 0 6px; padding: 0 2px;
|
||||||
@@ -1421,7 +1446,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
transition: color var(--transition), background var(--transition);
|
transition: color var(--transition), background var(--transition);
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
.sync-icon:hover { background: var(--bg-hover); color: var(--text-2); }
|
.sync-icon:hover { background: var(--hover-highlight); color: var(--text-2); }
|
||||||
.sync-icon.on { color: var(--primary); background: var(--primary-dim); }
|
.sync-icon.on { color: var(--primary); background: var(--primary-dim); }
|
||||||
.sync-icon .si-on, .sync-icon .si-off { align-items: center; }
|
.sync-icon .si-on, .sync-icon .si-off { align-items: center; }
|
||||||
.sync-icon .si-on { display: none; }
|
.sync-icon .si-on { display: none; }
|
||||||
@@ -1713,7 +1738,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
padding: 2px 6px; border-radius: var(--radius-sm);
|
padding: 2px 6px; border-radius: var(--radius-sm);
|
||||||
transition: background var(--transition), color var(--transition);
|
transition: background var(--transition), color var(--transition);
|
||||||
}
|
}
|
||||||
.dtp-nav-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
.dtp-nav-btn:hover { background: var(--hover-highlight); color: var(--text-1); }
|
||||||
/* Day-of-week headers */
|
/* Day-of-week headers */
|
||||||
.dtp-grid {
|
.dtp-grid {
|
||||||
display: grid; grid-template-columns: repeat(7, 1fr);
|
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||||
@@ -1730,9 +1755,9 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
font-size: 13px; font-weight: 500; color: var(--text-1);
|
font-size: 13px; font-weight: 500; color: var(--text-1);
|
||||||
cursor: pointer; transition: background var(--transition);
|
cursor: pointer; transition: background var(--transition);
|
||||||
}
|
}
|
||||||
.dtp-day:hover { background: var(--bg-hover); }
|
.dtp-day:hover { background: var(--day-hover-color); }
|
||||||
.dtp-day.other { color: var(--text-3); }
|
.dtp-day.other { color: var(--text-3); }
|
||||||
.dtp-day.other:hover { background: var(--bg-hover); }
|
.dtp-day.other:hover { background: var(--day-hover-color); }
|
||||||
.dtp-day.today { color: var(--primary); font-weight: 700; }
|
.dtp-day.today { color: var(--primary); font-weight: 700; }
|
||||||
.dtp-day.selected {
|
.dtp-day.selected {
|
||||||
background: var(--primary) !important;
|
background: var(--primary) !important;
|
||||||
@@ -2327,7 +2352,7 @@ a { color: var(--primary); text-decoration: none; }
|
|||||||
.group-emoji { flex: 0 0 auto; font-size: 16px; cursor: pointer; line-height: 1; }
|
.group-emoji { flex: 0 0 auto; font-size: 16px; cursor: pointer; line-height: 1; }
|
||||||
.cal-shared-flag { flex: 0 0 auto; font-size: 12px; opacity: .8; }
|
.cal-shared-flag { flex: 0 0 auto; font-size: 12px; opacity: .8; }
|
||||||
/* Read-only (shared with me) indicator: a struck-through pencil. */
|
/* Read-only (shared with me) indicator: a struck-through pencil. */
|
||||||
.cal-readonly-flag { color: var(--text-3); opacity: .7; margin-left: 2px; }
|
.cal-readonly-flag { color: var(--icon-inactive-color); opacity: .7; margin-left: 2px; }
|
||||||
.group-icon-picker { display: flex; flex-wrap: wrap; gap: 6px; }
|
.group-icon-picker { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
.group-icon-opt {
|
.group-icon-opt {
|
||||||
width: 38px; height: 38px;
|
width: 38px; height: 38px;
|
||||||
|
|||||||
@@ -487,6 +487,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Generic Confirm Dialog (styled replacement for window.confirm) -->
|
||||||
|
<div id="modal-confirm" class="modal-overlay hidden">
|
||||||
|
<div class="modal-card" style="max-width:400px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="confirm-title">Bestätigen</h3>
|
||||||
|
<button class="icon-btn modal-close" data-modal="modal-confirm">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="confirm-text"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="btn btn-ghost" id="confirm-cancel" data-modal="modal-confirm" data-i18n="cancel">Abbrechen</button>
|
||||||
|
<button class="btn btn-danger" id="confirm-ok" data-i18n="delete">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Event Detail Popup -->
|
<!-- Event Detail Popup -->
|
||||||
<div id="popup-event" class="event-popup hidden">
|
<div id="popup-event" class="event-popup hidden">
|
||||||
<div class="popup-header">
|
<div class="popup-header">
|
||||||
@@ -815,6 +833,12 @@
|
|||||||
<button type="button" class="sync-toggle sync-toggle-lg" id="cfg-sync-all" role="switch" aria-checked="false"></button>
|
<button type="button" class="sync-toggle sync-toggle-lg" id="cfg-sync-all" role="switch" aria-checked="false"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-table" id="settings-appearance-table"></div>
|
<div class="settings-table" id="settings-appearance-table"></div>
|
||||||
|
<div class="theme-io-row">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-export" data-i18n="theme_export">Theme exportieren</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="cfg-theme-import" data-i18n="theme_import">Theme importieren</button>
|
||||||
|
<a class="theme-io-help" id="cfg-theme-help" href="https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md" target="_blank" rel="noopener noreferrer" data-i18n-title="theme_docs_hint" title="Erklärung der Theme-Parameter">?</a>
|
||||||
|
<input type="file" id="cfg-theme-file" accept=".theme,application/json,.json" hidden />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Kalender: einheitliche Tabelle aller Kalender aus allen Quellen -->
|
<!-- Kalender: einheitliche Tabelle aller Kalender aus allen Quellen -->
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './api.js';
|
import { api } from './api.js';
|
||||||
import { applyTheme, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, renderDescriptionHtml } from './utils.js';
|
import { applyTheme, applyFavicon, isToday, isSameDay, toLocalDatetimeInput, toDateInput, dateKey, dayOfWeek, weekStart, renderDescriptionHtml } 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';
|
||||||
@@ -151,6 +151,7 @@ export async function initCalendar() {
|
|||||||
|
|
||||||
setLang(settings.language || 'de');
|
setLang(settings.language || 'de');
|
||||||
applyTheme(settings);
|
applyTheme(settings);
|
||||||
|
applyFavicon(settings.primary_color);
|
||||||
updateViewButtons();
|
updateViewButtons();
|
||||||
renderCalendarList();
|
renderCalendarList();
|
||||||
renderMiniCal();
|
renderMiniCal();
|
||||||
@@ -775,7 +776,7 @@ function renderCalendarList() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
const groupVisibleId = state.settings && state.settings.group_visible_calendar_id;
|
const groupVisibleId = state.settings && state.settings.group_visible_calendar_id;
|
||||||
state.localCalendars.filter(c => c.owned !== false && !c.group).forEach(cal => {
|
state.localCalendars.filter(c => c.owned !== false && !c.group && !c.sidebar_hidden).forEach(cal => {
|
||||||
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: true, remindersEnabled: cal.reminders_enabled !== false,
|
reminders: true, remindersEnabled: cal.reminders_enabled !== false,
|
||||||
@@ -802,7 +803,7 @@ function renderCalendarList() {
|
|||||||
sourceLabel: `${t('groups_title')} · ${cal.shared_by || ''}`,
|
sourceLabel: `${t('groups_title')} · ${cal.shared_by || ''}`,
|
||||||
isGroupCal: true, groupIcon: groupIconForLocalCal(cal.id), remove: null });
|
isGroupCal: true, groupIcon: groupIconForLocalCal(cal.id), remove: null });
|
||||||
});
|
});
|
||||||
state.icalSubscriptions.forEach(sub => {
|
state.icalSubscriptions.filter(s => !s.sidebar_hidden).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}"`,
|
||||||
name: sub.name, color: sub.color, enabled: sub.enabled,
|
name: sub.name, color: sub.color, enabled: sub.enabled,
|
||||||
reminders: true, remindersEnabled: sub.reminders_enabled !== false,
|
reminders: true, remindersEnabled: sub.reminders_enabled !== false,
|
||||||
@@ -1095,13 +1096,13 @@ function renderCalendarList() {
|
|||||||
}
|
}
|
||||||
cacheCalId = calId;
|
cacheCalId = calId;
|
||||||
} else if (source === 'local') {
|
} else if (source === 'local') {
|
||||||
if (!confirm(t('confirm_delete_local_cal'))) return;
|
if (!await confirmModal(t('confirm_delete_local_cal'), { title: t('confirm_delete_cal_title'), okLabel: t('delete') })) return;
|
||||||
const calId = parseInt(btn.dataset.calId);
|
const calId = parseInt(btn.dataset.calId);
|
||||||
await api.delete(`/local/calendars/${calId}`);
|
await api.delete(`/local/calendars/${calId}`);
|
||||||
state.localCalendars = state.localCalendars.filter(c => c.id !== calId);
|
state.localCalendars = state.localCalendars.filter(c => c.id !== calId);
|
||||||
cacheCalId = `local-${calId}`;
|
cacheCalId = `local-${calId}`;
|
||||||
} else if (source === 'ical') {
|
} else if (source === 'ical') {
|
||||||
if (!confirm(t('confirm_remove_ical'))) return;
|
if (!await confirmModal(t('confirm_remove_ical'), { title: t('confirm_remove_ical_title'), okLabel: t('delete') })) return;
|
||||||
const subId = parseInt(btn.dataset.subId);
|
const subId = parseInt(btn.dataset.subId);
|
||||||
await api.delete(`/ical/subscriptions/${subId}`);
|
await api.delete(`/ical/subscriptions/${subId}`);
|
||||||
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId);
|
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== subId);
|
||||||
@@ -1490,6 +1491,30 @@ function showDeleteConfirm(ev) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Styled yes/no dialog — a drop-in replacement for window.confirm().
|
||||||
|
// Returns a Promise<boolean>. `danger` (default true) shows a red confirm button.
|
||||||
|
function confirmModal(message, { title, okLabel, danger = true } = {}) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const modal = document.getElementById('modal-confirm');
|
||||||
|
document.getElementById('confirm-title').textContent = title || t('confirm_generic_title');
|
||||||
|
document.getElementById('confirm-text').textContent = message || '';
|
||||||
|
const okBtn = document.getElementById('confirm-ok');
|
||||||
|
okBtn.textContent = okLabel || t('confirm');
|
||||||
|
okBtn.classList.toggle('btn-danger', danger);
|
||||||
|
okBtn.classList.toggle('btn-primary', !danger);
|
||||||
|
openModal('modal-confirm');
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
okBtn.onclick = null;
|
||||||
|
modal.querySelectorAll('[data-modal="modal-confirm"]').forEach(b => b.onclick = null);
|
||||||
|
};
|
||||||
|
okBtn.onclick = () => { cleanup(); closeModal('modal-confirm'); resolve(true); };
|
||||||
|
modal.querySelectorAll('[data-modal="modal-confirm"]').forEach(b => {
|
||||||
|
b.onclick = () => { cleanup(); closeModal('modal-confirm'); resolve(false); };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showDayContextMenu(date, mouseEvent) {
|
function showDayContextMenu(date, mouseEvent) {
|
||||||
document.querySelectorAll('.cal-context-menu').forEach(m => m.remove());
|
document.querySelectorAll('.cal-context-menu').forEach(m => m.remove());
|
||||||
|
|
||||||
@@ -3511,6 +3536,114 @@ function toggleSyncAll() {
|
|||||||
updateSyncAllToggle();
|
updateSyncAllToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Human-facing docs describing every theme parameter (linked from export files).
|
||||||
|
const THEME_DOCS_URL = 'https://git.scarriffle.com/Scarriffle/Calendarr/src/branch/beta/THEME.md';
|
||||||
|
|
||||||
|
// Keys that are colours (validated on import).
|
||||||
|
function colorSettingKeys() {
|
||||||
|
const out = new Set();
|
||||||
|
SETTING_GROUPS.forEach(g => g.rows.forEach(r => { if (r.type === 'color') out.add(r.key); }));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the current appearance (colours + display settings) as a .theme file
|
||||||
|
// named after the local date+time, with a link to the parameter docs.
|
||||||
|
function exportTheme() {
|
||||||
|
const now = new Date();
|
||||||
|
const p = (n) => String(n).padStart(2, '0');
|
||||||
|
const stamp = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}_${p(now.getHours())}-${p(now.getMinutes())}`;
|
||||||
|
const payload = {
|
||||||
|
_format: 'calendarr-theme',
|
||||||
|
_version: 1,
|
||||||
|
_docs: THEME_DOCS_URL,
|
||||||
|
exported_at: now.toISOString(),
|
||||||
|
settings: readAppearanceTable(),
|
||||||
|
};
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${stamp}.theme`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a chosen .theme file locally and apply its values. A theme may be
|
||||||
|
// partial (only some parameters) — only the keys present are written; omitted
|
||||||
|
// keys are left untouched (the server accepts partial updates). If the file
|
||||||
|
// contains parameters this version doesn't know, the user is asked whether to
|
||||||
|
// import the rest anyway.
|
||||||
|
async function importThemeFile(input) {
|
||||||
|
const file = input.files && input.files[0];
|
||||||
|
input.value = ''; // allow re-importing the same file later
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
let incoming;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(await file.text());
|
||||||
|
incoming = (data && typeof data === 'object' && data.settings && typeof data.settings === 'object')
|
||||||
|
? data.settings : data;
|
||||||
|
if (!incoming || typeof incoming !== 'object') throw new Error('bad');
|
||||||
|
} catch (e) {
|
||||||
|
showToast(t('theme_import_invalid'), true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const known = new Set(SYNCABLE_KEYS);
|
||||||
|
const unknownKeys = Object.keys(incoming).filter(k => !known.has(k));
|
||||||
|
if (unknownKeys.length) {
|
||||||
|
const ok = await confirmModal(
|
||||||
|
t('theme_unknown_params', { count: unknownKeys.length, names: unknownKeys.join(', ') }),
|
||||||
|
{ title: t('theme_unknown_title'), okLabel: t('theme_import_rest'), danger: false }
|
||||||
|
);
|
||||||
|
if (!ok) return; // user declined → import nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the known, valid parameters to apply.
|
||||||
|
const colorKeys = colorSettingKeys();
|
||||||
|
const toApply = {};
|
||||||
|
for (const key of SYNCABLE_KEYS) {
|
||||||
|
if (!(key in incoming)) continue;
|
||||||
|
let val = incoming[key];
|
||||||
|
if (colorKeys.has(key)) {
|
||||||
|
const norm = normalizeHex(val, null);
|
||||||
|
if (!norm) continue; // skip invalid colour values
|
||||||
|
val = norm;
|
||||||
|
}
|
||||||
|
toApply[key] = val;
|
||||||
|
}
|
||||||
|
const applied = Object.keys(toApply);
|
||||||
|
if (!applied.length) { showToast(t('theme_import_invalid'), true); return; }
|
||||||
|
|
||||||
|
// Persist ONLY the present keys (partial). Keys whose sync flag is on are sent
|
||||||
|
// to the server; the rest just update this browser's local copy.
|
||||||
|
const local = loadLocal();
|
||||||
|
const serverPayload = {};
|
||||||
|
for (const key of applied) {
|
||||||
|
state.settings[key] = toApply[key];
|
||||||
|
local[key] = toApply[key];
|
||||||
|
if (state.syncFlags[key]) serverPayload[key] = toApply[key];
|
||||||
|
}
|
||||||
|
saveLocal(local);
|
||||||
|
try {
|
||||||
|
if (Object.keys(serverPayload).length) await api.put('/settings/', serverPayload);
|
||||||
|
state.serverSettings = { ...state.serverSettings, ...serverPayload };
|
||||||
|
state.settings = mergeEffective(state.serverSettings, state.syncFlags, loadLocal());
|
||||||
|
renderSettingsTable();
|
||||||
|
applyTheme(state.settings);
|
||||||
|
applyFavicon(state.settings.primary_color);
|
||||||
|
setLang(state.settings.language);
|
||||||
|
renderCalendarList();
|
||||||
|
renderMiniCal();
|
||||||
|
fetchAndRender();
|
||||||
|
showToast(t('theme_imported_saved', { count: applied.length }));
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save profile identity fields (name/login/email/hidden) — server rotates the
|
// Save profile identity fields (name/login/email/hidden) — server rotates the
|
||||||
// JWT if the login name changed.
|
// JWT if the login name changed.
|
||||||
async function saveProfileFields() {
|
async function saveProfileFields() {
|
||||||
@@ -3572,7 +3705,7 @@ function renderGoogleAccounts() {
|
|||||||
});
|
});
|
||||||
list.querySelectorAll('[data-disconnect-acc]').forEach(btn => {
|
list.querySelectorAll('[data-disconnect-acc]').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
if (!confirm(t('confirm_google_disconnect'))) return;
|
if (!await confirmModal(t('confirm_google_disconnect'), { title: t('disconnect'), okLabel: t('disconnect') })) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/google/accounts/${btn.dataset.disconnectAcc}`);
|
await api.delete(`/google/accounts/${btn.dataset.disconnectAcc}`);
|
||||||
state.googleAccounts = state.googleAccounts.filter(a => a.id !== parseInt(btn.dataset.disconnectAcc));
|
state.googleAccounts = state.googleAccounts.filter(a => a.id !== parseInt(btn.dataset.disconnectAcc));
|
||||||
@@ -3617,7 +3750,7 @@ function renderAllAccounts() {
|
|||||||
});
|
});
|
||||||
caldavList.querySelectorAll('[data-caldav-disconnect]').forEach(btn => {
|
caldavList.querySelectorAll('[data-caldav-disconnect]').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
if (!confirm(t('confirm_caldav_disconnect'))) return;
|
if (!await confirmModal(t('confirm_caldav_disconnect'), { title: t('disconnect'), okLabel: t('disconnect') })) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/caldav/accounts/${btn.dataset.caldavDisconnect}`);
|
await api.delete(`/caldav/accounts/${btn.dataset.caldavDisconnect}`);
|
||||||
state.accounts = state.accounts.filter(a => a.id !== parseInt(btn.dataset.caldavDisconnect));
|
state.accounts = state.accounts.filter(a => a.id !== parseInt(btn.dataset.caldavDisconnect));
|
||||||
@@ -3691,7 +3824,7 @@ function renderAllAccounts() {
|
|||||||
).join('');
|
).join('');
|
||||||
icalList.querySelectorAll('[data-ical-delete]').forEach(btn => {
|
icalList.querySelectorAll('[data-ical-delete]').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
if (!confirm(t('confirm_remove_ical'))) return;
|
if (!await confirmModal(t('confirm_remove_ical'), { title: t('confirm_remove_ical_title'), okLabel: t('delete') })) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/ical/subscriptions/${btn.dataset.icalDelete}`);
|
await api.delete(`/ical/subscriptions/${btn.dataset.icalDelete}`);
|
||||||
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== parseInt(btn.dataset.icalDelete));
|
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== parseInt(btn.dataset.icalDelete));
|
||||||
@@ -3735,7 +3868,7 @@ function renderAllAccounts() {
|
|||||||
});
|
});
|
||||||
haList.querySelectorAll('[data-ha-disconnect]').forEach(btn => {
|
haList.querySelectorAll('[data-ha-disconnect]').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
if (!confirm('Home Assistant Konto wirklich trennen?')) return;
|
if (!await confirmModal(t('confirm_ha_disconnect'), { title: t('disconnect'), okLabel: t('disconnect') })) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/homeassistant/accounts/${btn.dataset.haDisconnect}`);
|
await api.delete(`/homeassistant/accounts/${btn.dataset.haDisconnect}`);
|
||||||
state.haAccounts = state.haAccounts.filter(a => a.id !== parseInt(btn.dataset.haDisconnect));
|
state.haAccounts = state.haAccounts.filter(a => a.id !== parseInt(btn.dataset.haDisconnect));
|
||||||
@@ -3951,7 +4084,7 @@ function renderCalendarTable() {
|
|||||||
rows += `<tr>
|
rows += `<tr>
|
||||||
<td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td>
|
<td>${dot(cal.color, '#34a853')}${escHtml(cal.name)}</td>
|
||||||
<td class="ct-src">Lokal</td>
|
<td class="ct-src">Lokal</td>
|
||||||
<td>—</td>
|
<td>${hid('local', cal.id, !cal.sidebar_hidden)}</td>
|
||||||
<td>${rem('local', cal.id, cal.reminders_enabled !== false)}</td>
|
<td>${rem('local', cal.id, cal.reminders_enabled !== false)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-ghost btn-sm" data-ct-share="${cal.id}">${t('share')}</button>
|
<button class="btn btn-ghost btn-sm" data-ct-share="${cal.id}">${t('share')}</button>
|
||||||
@@ -4095,9 +4228,13 @@ function renderCalendarTable() {
|
|||||||
const hidden = btn.dataset.ctVisible === '1'; // currently visible → we're hiding it
|
const hidden = btn.dataset.ctVisible === '1'; // currently visible → we're hiding it
|
||||||
try {
|
try {
|
||||||
if (src === 'ical') {
|
if (src === 'ical') {
|
||||||
await api.put(`/ical/subscriptions/${id}`, { sidebar_hidden: hidden });
|
await api.put(`/ical/subscriptions/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
|
||||||
const s = state.icalSubscriptions.find(s => s.id === id);
|
const s = state.icalSubscriptions.find(s => s.id === id);
|
||||||
if (s) s.sidebar_hidden = hidden;
|
if (s) { s.sidebar_hidden = hidden; s.enabled = !hidden; }
|
||||||
|
} else if (src === 'local') {
|
||||||
|
await api.put(`/local/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
|
||||||
|
const c = state.localCalendars.find(c => c.id === id);
|
||||||
|
if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; }
|
||||||
} else if (src === 'caldav') {
|
} else if (src === 'caldav') {
|
||||||
await api.put(`/caldav/calendars/${id}`, { enabled: !hidden, sidebar_hidden: hidden });
|
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; } }
|
for (const acc of state.accounts) { const c = acc.calendars.find(c => c.id === id); if (c) { c.sidebar_hidden = hidden; c.enabled = !hidden; } }
|
||||||
@@ -4164,8 +4301,8 @@ function renderCalendarTable() {
|
|||||||
const src = btn.dataset.ctDisc, id = parseInt(btn.dataset.ctId);
|
const src = btn.dataset.ctDisc, id = parseInt(btn.dataset.ctId);
|
||||||
const msg = src === 'caldav' ? t('confirm_caldav_disconnect')
|
const msg = src === 'caldav' ? t('confirm_caldav_disconnect')
|
||||||
: src === 'google' ? t('confirm_google_disconnect')
|
: src === 'google' ? t('confirm_google_disconnect')
|
||||||
: 'Konto wirklich trennen?';
|
: t('confirm_ha_disconnect');
|
||||||
if (!confirm(msg)) return;
|
if (!await confirmModal(msg, { title: t('disconnect'), okLabel: t('disconnect') })) return;
|
||||||
try {
|
try {
|
||||||
if (src === 'caldav') {
|
if (src === 'caldav') {
|
||||||
await api.delete(`/caldav/accounts/${id}`);
|
await api.delete(`/caldav/accounts/${id}`);
|
||||||
@@ -4240,7 +4377,8 @@ function renderCalendarTable() {
|
|||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const src = btn.dataset.ctDel, id = parseInt(btn.dataset.ctId);
|
const src = btn.dataset.ctDel, id = parseInt(btn.dataset.ctId);
|
||||||
const msg = src === 'local' ? t('confirm_delete_local_cal') : t('confirm_remove_ical');
|
const msg = src === 'local' ? t('confirm_delete_local_cal') : t('confirm_remove_ical');
|
||||||
if (!confirm(msg)) return;
|
const title = src === 'local' ? t('confirm_delete_cal_title') : t('confirm_remove_ical_title');
|
||||||
|
if (!await confirmModal(msg, { title, okLabel: t('delete') })) return;
|
||||||
try {
|
try {
|
||||||
if (src === 'local') {
|
if (src === 'local') {
|
||||||
await api.delete(`/local/calendars/${id}`);
|
await api.delete(`/local/calendars/${id}`);
|
||||||
@@ -4303,6 +4441,16 @@ function bindSettingsModal() {
|
|||||||
const syncAllBtn = document.getElementById('cfg-sync-all');
|
const syncAllBtn = document.getElementById('cfg-sync-all');
|
||||||
if (syncAllBtn) syncAllBtn.addEventListener('click', toggleSyncAll);
|
if (syncAllBtn) syncAllBtn.addEventListener('click', toggleSyncAll);
|
||||||
|
|
||||||
|
// Theme export / import (client-side .theme files).
|
||||||
|
const themeExportBtn = document.getElementById('cfg-theme-export');
|
||||||
|
if (themeExportBtn) themeExportBtn.addEventListener('click', exportTheme);
|
||||||
|
const themeImportBtn = document.getElementById('cfg-theme-import');
|
||||||
|
const themeFileInput = document.getElementById('cfg-theme-file');
|
||||||
|
if (themeImportBtn && themeFileInput) {
|
||||||
|
themeImportBtn.addEventListener('click', () => themeFileInput.click());
|
||||||
|
themeFileInput.addEventListener('change', () => importThemeFile(themeFileInput));
|
||||||
|
}
|
||||||
|
|
||||||
// Panel navigation
|
// Panel navigation
|
||||||
document.querySelectorAll('.settings-nav-btn').forEach(btn => {
|
document.querySelectorAll('.settings-nav-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel));
|
btn.addEventListener('click', () => activateSettingsPanel(btn.dataset.panel));
|
||||||
@@ -4357,6 +4505,7 @@ function bindSettingsModal() {
|
|||||||
weekStartDay = state.settings.week_start_day;
|
weekStartDay = state.settings.week_start_day;
|
||||||
setLang(appearance.language);
|
setLang(appearance.language);
|
||||||
applyTheme(state.settings);
|
applyTheme(state.settings);
|
||||||
|
applyFavicon(state.settings.primary_color);
|
||||||
showToast(t('settings_saved'));
|
showToast(t('settings_saved'));
|
||||||
closeModal('modal-settings');
|
closeModal('modal-settings');
|
||||||
renderCalendarList();
|
renderCalendarList();
|
||||||
|
|||||||
@@ -78,6 +78,27 @@ const translations = {
|
|||||||
settings_line_color: 'Linienfarbe',
|
settings_line_color: 'Linienfarbe',
|
||||||
settings_bg_color: 'Hintergrundfarbe',
|
settings_bg_color: 'Hintergrundfarbe',
|
||||||
settings_surface_color: 'Seitenleisten-/Oberflächenfarbe',
|
settings_surface_color: 'Seitenleisten-/Oberflächenfarbe',
|
||||||
|
settings_hover_highlight_color: 'Hover-Highlight (Buttons, Menüs)',
|
||||||
|
settings_icon_inactive_color: 'Sidebar-Icons inaktiv (Ruhe/aus)',
|
||||||
|
settings_icon_active_color: 'Sidebar-Icons aktiv (Hover/an)',
|
||||||
|
settings_day_hover_color: 'Kalendertag Hover',
|
||||||
|
settings_day_selected_color: 'Ausgewählter Tag',
|
||||||
|
settings_day_bg_color: 'Tag-Hintergrund',
|
||||||
|
settings_today_bg_color: 'Heutiger Tag Hintergrund',
|
||||||
|
theme_export: 'Theme exportieren',
|
||||||
|
theme_import: 'Theme importieren',
|
||||||
|
theme_imported: 'Theme importiert – zum Übernehmen speichern',
|
||||||
|
theme_imported_saved: '{count} Parameter importiert & gespeichert',
|
||||||
|
theme_import_invalid: 'Ungültige Theme-Datei',
|
||||||
|
theme_docs_hint: 'Erklärung der Theme-Parameter',
|
||||||
|
theme_unknown_title: 'Unbekannte Parameter',
|
||||||
|
theme_unknown_params: 'Diese Theme-Datei enthält {count} Parameter, die diese Version nicht kennt ({names}). Den Rest trotzdem importieren?',
|
||||||
|
theme_import_rest: 'Rest importieren',
|
||||||
|
confirm_generic_title: 'Bestätigen',
|
||||||
|
confirm: 'OK',
|
||||||
|
confirm_delete_cal_title: 'Kalender löschen',
|
||||||
|
confirm_remove_ical_title: 'Abo entfernen',
|
||||||
|
confirm_ha_disconnect: 'Home Assistant Konto wirklich trennen?',
|
||||||
reset: 'Reset',
|
reset: 'Reset',
|
||||||
settings_text_contrast: 'Schriftkontrast',
|
settings_text_contrast: 'Schriftkontrast',
|
||||||
settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte',
|
settings_text_contrast_desc: 'Helligkeit der Beschriftungen und Texte',
|
||||||
@@ -435,6 +456,27 @@ const translations = {
|
|||||||
settings_line_color: 'Line color',
|
settings_line_color: 'Line color',
|
||||||
settings_bg_color: 'Background color',
|
settings_bg_color: 'Background color',
|
||||||
settings_surface_color: 'Sidebar / surface color',
|
settings_surface_color: 'Sidebar / surface color',
|
||||||
|
settings_hover_highlight_color: 'Hover highlight (buttons, menus)',
|
||||||
|
settings_icon_inactive_color: 'Sidebar icons inactive (resting/off)',
|
||||||
|
settings_icon_active_color: 'Sidebar icons active (hover/on)',
|
||||||
|
settings_day_hover_color: 'Calendar day hover',
|
||||||
|
settings_day_selected_color: 'Selected day',
|
||||||
|
settings_day_bg_color: 'Day background',
|
||||||
|
settings_today_bg_color: 'Today background',
|
||||||
|
theme_export: 'Export theme',
|
||||||
|
theme_import: 'Import theme',
|
||||||
|
theme_imported: 'Theme imported – click Save to apply',
|
||||||
|
theme_imported_saved: '{count} parameters imported & saved',
|
||||||
|
theme_import_invalid: 'Invalid theme file',
|
||||||
|
theme_docs_hint: 'Explanation of theme parameters',
|
||||||
|
theme_unknown_title: 'Unknown parameters',
|
||||||
|
theme_unknown_params: 'This theme file contains {count} parameters this version does not know ({names}). Import the rest anyway?',
|
||||||
|
theme_import_rest: 'Import the rest',
|
||||||
|
confirm_generic_title: 'Confirm',
|
||||||
|
confirm: 'OK',
|
||||||
|
confirm_delete_cal_title: 'Delete calendar',
|
||||||
|
confirm_remove_ical_title: 'Remove subscription',
|
||||||
|
confirm_ha_disconnect: 'Really disconnect the Home Assistant account?',
|
||||||
reset: 'Reset',
|
reset: 'Reset',
|
||||||
settings_text_contrast: 'Text contrast',
|
settings_text_contrast: 'Text contrast',
|
||||||
settings_text_contrast_desc: 'Brightness of labels and text',
|
settings_text_contrast_desc: 'Brightness of labels and text',
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ export const DEFAULT_COLORS = {
|
|||||||
surface_color: '#1A1A1A',
|
surface_color: '#1A1A1A',
|
||||||
month_divider_color: '#7090C0',
|
month_divider_color: '#7090C0',
|
||||||
month_label_color: '#7090C0',
|
month_label_color: '#7090C0',
|
||||||
|
// Fine-grained element colours. Defaults equal the previously-derived look
|
||||||
|
// (see THEME.md). day_selected/today_bg are applied as a subtle tint of the
|
||||||
|
// chosen colour; the rest are applied solid.
|
||||||
|
hover_highlight_color: '#2A2A38',
|
||||||
|
icon_inactive_color: '#9090AA',
|
||||||
|
icon_active_color: '#E8E8F0',
|
||||||
|
day_hover_color: '#2A2A38',
|
||||||
|
day_selected_color: '#4285F4',
|
||||||
|
day_bg_color: '#000000',
|
||||||
|
today_bg_color: '#4285F4',
|
||||||
};
|
};
|
||||||
|
|
||||||
// The syncable settings the web client exposes, grouped into table sections.
|
// The syncable settings the web client exposes, grouped into table sections.
|
||||||
@@ -68,6 +78,13 @@ export const SETTING_GROUPS = [
|
|||||||
{ key: 'line_color', labelKey: 'settings_line_color', type: 'color' },
|
{ key: 'line_color', labelKey: 'settings_line_color', type: 'color' },
|
||||||
{ key: 'month_divider_color', labelKey: 'settings_month_divider_color', type: 'color' },
|
{ key: 'month_divider_color', labelKey: 'settings_month_divider_color', type: 'color' },
|
||||||
{ key: 'month_label_color', labelKey: 'settings_month_label_color', type: 'color' },
|
{ key: 'month_label_color', labelKey: 'settings_month_label_color', type: 'color' },
|
||||||
|
{ key: 'hover_highlight_color', labelKey: 'settings_hover_highlight_color', type: 'color' },
|
||||||
|
{ key: 'icon_inactive_color', labelKey: 'settings_icon_inactive_color', type: 'color' },
|
||||||
|
{ key: 'icon_active_color', labelKey: 'settings_icon_active_color', type: 'color' },
|
||||||
|
{ key: 'day_hover_color', labelKey: 'settings_day_hover_color', type: 'color' },
|
||||||
|
{ key: 'day_selected_color', labelKey: 'settings_day_selected_color', type: 'color' },
|
||||||
|
{ key: 'day_bg_color', labelKey: 'settings_day_bg_color', type: 'color' },
|
||||||
|
{ key: 'today_bg_color', labelKey: 'settings_today_bg_color', type: 'color' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -147,6 +147,43 @@ export function applyTheme(settings) {
|
|||||||
|
|
||||||
root.style.setProperty('--month-divider-color', settings.month_divider_color || DEFAULT_COLORS.month_divider_color);
|
root.style.setProperty('--month-divider-color', settings.month_divider_color || DEFAULT_COLORS.month_divider_color);
|
||||||
root.style.setProperty('--month-label-color', settings.month_label_color || DEFAULT_COLORS.month_label_color);
|
root.style.setProperty('--month-label-color', settings.month_label_color || DEFAULT_COLORS.month_label_color);
|
||||||
|
|
||||||
|
// Fine-grained element colours. Each is applied ONLY when the user set an
|
||||||
|
// explicit value; otherwise the :root default (which references a derived
|
||||||
|
// variable) stays in effect, so the look is unchanged until customised.
|
||||||
|
// day_selected_color / today_bg_color feed a *-base variable that CSS turns
|
||||||
|
// into a subtle tint via color-mix; the rest are applied as-is.
|
||||||
|
const setIf = (varName, value) => { if (value) root.style.setProperty(varName, value); };
|
||||||
|
setIf('--hover-highlight', settings.hover_highlight_color);
|
||||||
|
setIf('--icon-inactive-color', settings.icon_inactive_color);
|
||||||
|
setIf('--icon-active-color', settings.icon_active_color);
|
||||||
|
setIf('--day-hover-color', settings.day_hover_color);
|
||||||
|
setIf('--day-selected-base', settings.day_selected_color);
|
||||||
|
setIf('--day-bg', settings.day_bg_color);
|
||||||
|
setIf('--today-bg-base', settings.today_bg_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tint the favicon (and browser theme-colour) to the current primary colour so
|
||||||
|
// the tab icon reflects the user's theme. Called at load and after saving —
|
||||||
|
// NOT on every live keystroke. Reuses the calendar glyph from favicon.svg.
|
||||||
|
export function applyFavicon(primaryColor) {
|
||||||
|
const color = primaryColor || DEFAULT_COLORS.primary_color;
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">`
|
||||||
|
+ '<rect x="3" y="4" width="18" height="18" rx="2"/>'
|
||||||
|
+ '<line x1="16" y1="2" x2="16" y2="6"/>'
|
||||||
|
+ '<line x1="8" y1="2" x2="8" y2="6"/>'
|
||||||
|
+ '<line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||||
|
const href = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||||
|
let link = document.querySelector('link[rel="icon"]');
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link');
|
||||||
|
link.rel = 'icon';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.type = 'image/svg+xml';
|
||||||
|
link.href = href;
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (meta) meta.setAttribute('content', color);
|
||||||
}
|
}
|
||||||
|
|
||||||
function luminance(hex) {
|
function luminance(hex) {
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
// Increment APP_VERSION with every code change
|
// Increment APP_VERSION with every code change
|
||||||
export const APP_VERSION = 'v82';
|
export const APP_VERSION = 'v84';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// the entry HTML / version files). New releases take effect on the next
|
// the entry HTML / version files). New releases take effect on the next
|
||||||
// reload, no manual SW unregister required.
|
// reload, no manual SW unregister required.
|
||||||
|
|
||||||
const CACHE_VERSION = 'calendarr-v30';
|
const CACHE_VERSION = 'calendarr-v32';
|
||||||
const OFFLINE_SHELL = ['/', '/index.html'];
|
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||||
|
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
|
|||||||
Reference in New Issue
Block a user