Compare commits
35 Commits
b8a578cf53
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a45bf6ea73 | ||
|
|
c632dabc31 | ||
|
|
995d9bb5b4 | ||
|
|
4c7cdc362a | ||
|
|
37e5507261 | ||
|
|
22a58e0d9f | ||
|
|
cea96660d9 | ||
|
|
6316ed3a6b | ||
|
|
12c869451b | ||
|
|
a7802778b6 | ||
|
|
7a89cc44f8 | ||
|
|
131f6b496d | ||
|
|
4ab07ddcc3 | ||
|
|
137694ed98 | ||
|
|
3d58e8fef8 | ||
|
|
91ae434e7c | ||
|
|
1f1eb582ed | ||
|
|
29ddc5acbb | ||
|
|
0ce77ccdf8 | ||
|
|
16ff434bef | ||
|
|
1a30b4066a | ||
|
|
aa12b83302 | ||
|
|
19258096e2 | ||
|
|
6baa07379f | ||
|
|
eb0684b99c | ||
|
|
ca09538971 | ||
|
|
d426f8985c | ||
|
|
89a1355149 | ||
|
|
f844ded57d | ||
|
|
ec85a5b5f3 | ||
|
|
af64e191ec | ||
|
|
52bc7066db | ||
|
|
7d5530e51c | ||
|
|
90bc4b1531 | ||
|
|
1eecf834a3 |
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 | `#58B900` |
|
||||
| `accent_color` | Accent — danger actions, the "now" line, reminders | `#45A148` |
|
||||
| `today_color` | "Today" accent: the day-number circle and today's labels | `#6FB669` |
|
||||
| `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) | `#2B2B2B` |
|
||||
| `line_color` | Borders and grid lines | `#3B3B3D` |
|
||||
| `month_divider_color` | The line marking a month change in the scrolling month view | `#95D25E` |
|
||||
| `month_label_color` | The month abbreviation shown at a month change | `#95D25E` |
|
||||
| `hover_highlight_color` | General interactive hover — buttons, menu items, list rows | `#2A2A38` |
|
||||
| `icon_inactive_color` | Sidebar action icons (notification bell *off*, hide/eye, delete/trash, "not editable") in their resting / off / not-hovered state | `#90AA91` |
|
||||
| `icon_active_color` | The same sidebar action icons when hovered, pressed, or *on* (e.g. notification bell enabled) | `#E8E8F0` |
|
||||
| `day_hover_color` | Hover background over a calendar day (month / week / quarter / agenda / mini-calendar / date picker) | `#2B382A` |
|
||||
| `day_selected_color` | The selected day — applied as a subtle tint of this colour | `#88EF9A` |
|
||||
| `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 | `#477650` |
|
||||
|
||||
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.
|
||||
63
backend/SETTINGS_SYNC.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 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
|
||||
sole authority** for *which* settings sync; clients must not duplicate that logic.
|
||||
This document is the shared contract all three clients implement identically.
|
||||
|
||||
## Canonical keys & default flags
|
||||
|
||||
Source of truth: `DEFAULT_SYNC` in `backend/routers/settings_router.py`. Keys use
|
||||
the server's snake_case field names.
|
||||
|
||||
| Key | Kind | Default sync |
|
||||
|---|---|---|
|
||||
| `default_view` | enum | ON |
|
||||
| `week_start_day` | enum | ON |
|
||||
| `dim_past_events` | bool | ON |
|
||||
| `hour_height` | enum(int) | ON |
|
||||
| `primary_color` `accent_color` `today_color` `text_color` `line_color` `bg_color` `month_divider_color` `month_label_color` | color hex | ON |
|
||||
| `default_event_duration_minutes` | enum(int) | ON |
|
||||
| `default_reminder_minutes` | enum(int, null=off) | ON |
|
||||
| `language` | enum | **OFF** |
|
||||
| `share_calendar_icon` | icon key | **OFF** |
|
||||
| `cache_months` | enum(int) | **OFF** |
|
||||
| `month_view_paged` | bool | **OFF** |
|
||||
|
||||
Settings **not** in this list are never synced by this mechanism:
|
||||
- Account-wide settings (`private_event_visibility`, `group_visible_calendar_id`,
|
||||
`directory_hidden`) — one value per account, always identical everywhere; they
|
||||
keep their existing dedicated endpoints/UI, not a sync toggle.
|
||||
- Platform-exclusive device prefs (e.g. iOS `liquid_glass`) — stay device-local.
|
||||
- Identity/security, calendar/account management, admin.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/settings/` returns every value **plus** `sync_flags`: a fully-resolved
|
||||
`{key: bool}` map covering exactly the keys above (stored overrides on top of
|
||||
`DEFAULT_SYNC`). Clients read this map verbatim — no client-side defaults.
|
||||
- `PUT /api/settings/` accepts a partial `sync_flags` map (merged account-wide,
|
||||
unknown keys ignored, untouched flags preserved) and partial value fields
|
||||
(`exclude_unset`; `text_color`/`line_color`/`bg_color`/… treated as
|
||||
nullable-reset per `NULLABLE_OVERRIDES`).
|
||||
|
||||
## Client rules
|
||||
|
||||
Each client keeps a **local copy** of every syncable value (UserDefaults /
|
||||
DataStore-SharedPreferences / localStorage) so that "not synced" works per device.
|
||||
|
||||
1. **On login / launch / foreground:** `GET /api/settings/` → values + `sync_flags`.
|
||||
2. **Pull:** for each syncable key, if `sync_flags[key]` is ON, adopt the server
|
||||
value into the local copy; if OFF, keep the local value.
|
||||
3. **Push (debounced, read-modify-write):** start from the current server snapshot,
|
||||
overwrite only keys whose flag is ON with the local value, `PUT`. Never push a
|
||||
key whose flag is OFF.
|
||||
4. **Toggle a flag ON:** set the flag true **and** push this device's current local
|
||||
value (it becomes the shared value). **OFF:** set false; keep the local value.
|
||||
5. **Global "share everything":** set all syncable flags true and push all local
|
||||
values. Global off: set all false.
|
||||
|
||||
The flag map itself is always account-wide and always fetched fresh; it is what a
|
||||
client consults to decide what to send/receive.
|
||||
@@ -54,7 +54,7 @@ def private_visibility_for(db: Session, user_id: int) -> str:
|
||||
# field (title/location/description/creator/calendar name/recurrence) can leak.
|
||||
_BUSY_KEEP = {
|
||||
"id", "url", "start", "end", "allDay", "calendar_id", "calendarColor",
|
||||
"source", "type", "owner", "is_group_event", "display_color",
|
||||
"source", "type", "owner", "is_group_event", "display_color", "read_only",
|
||||
}
|
||||
|
||||
|
||||
@@ -102,12 +102,14 @@ def build_local_event_dict(
|
||||
creator: Optional[dict] = None,
|
||||
owner: Optional[dict] = None,
|
||||
is_group_event: bool = False,
|
||||
read_only: bool = False,
|
||||
) -> dict:
|
||||
"""Build the unified dict for a single local event (or occurrence).
|
||||
|
||||
``start``/``end``/``all_day`` override the stored values (used when emitting
|
||||
an expanded recurrence occurrence). ``owner``/``is_group_event`` are only set
|
||||
by the group combined view.
|
||||
by the group combined view. ``read_only`` marks events the requester may not
|
||||
edit (someone else's calendar), so clients can hide edit/delete.
|
||||
"""
|
||||
d = {
|
||||
"id": ev.uid,
|
||||
@@ -130,10 +132,31 @@ def build_local_event_dict(
|
||||
"private": bool(ev.is_private),
|
||||
"reminders": [int(x) for x in (ev.reminders or "").split(",") if x.strip().lstrip("-").isdigit()],
|
||||
}
|
||||
# Birthday calendars: the server owns the presentation. It flags the event so
|
||||
# clients can show a cake icon, appends the age computed for THIS occurrence
|
||||
# (so it stays correct as years pass), and — when the calendar defines a
|
||||
# "notify N days before" — injects a reminder so the mobile schedulers fire
|
||||
# it. Web has no notification delivery, so the reminder is display-only there.
|
||||
if getattr(cal, "is_birthday", False):
|
||||
d["is_birthday"] = True
|
||||
display = ev.title
|
||||
if ev.birth_year:
|
||||
try:
|
||||
occ_year = int(str(d["start"])[:4])
|
||||
age = occ_year - int(ev.birth_year)
|
||||
if age >= 0:
|
||||
display = f"{ev.title} ({age})"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
d["display_title"] = display
|
||||
if not d["reminders"] and cal.birthday_notify_days_before is not None:
|
||||
d["reminders"] = [int(cal.birthday_notify_days_before) * 1440]
|
||||
if owner is not None:
|
||||
d["owner"] = owner
|
||||
if is_group_event:
|
||||
d["is_group_event"] = True
|
||||
if read_only:
|
||||
d["read_only"] = True
|
||||
return d
|
||||
|
||||
|
||||
@@ -146,6 +169,7 @@ def expand_recurring_local(
|
||||
creator: Optional[dict] = None,
|
||||
owner: Optional[dict] = None,
|
||||
is_group_event: bool = False,
|
||||
read_only: bool = False,
|
||||
) -> list:
|
||||
"""Expand a recurring LocalEvent into individual occurrences in the range."""
|
||||
results = []
|
||||
@@ -177,6 +201,7 @@ def expand_recurring_local(
|
||||
ev, local_cal,
|
||||
start=occ_start.isoformat(), end=occ_end.isoformat(), all_day=True,
|
||||
creator=creator, owner=owner, is_group_event=is_group_event,
|
||||
read_only=read_only,
|
||||
))
|
||||
else:
|
||||
ev_start = dt_datetime.fromisoformat(ev_start_str)
|
||||
@@ -203,11 +228,13 @@ def expand_recurring_local(
|
||||
ev, local_cal,
|
||||
start=occ.isoformat(), end=occ_end.isoformat(), all_day=False,
|
||||
creator=creator, owner=owner, is_group_event=is_group_event,
|
||||
read_only=read_only,
|
||||
))
|
||||
except Exception as exc:
|
||||
logger.warning("Error expanding recurring event %s: %s", ev.uid, exc)
|
||||
# Fall back to a single event.
|
||||
results.append(build_local_event_dict(
|
||||
ev, local_cal, creator=creator, owner=owner, is_group_event=is_group_event,
|
||||
read_only=read_only,
|
||||
))
|
||||
return results
|
||||
|
||||
@@ -17,7 +17,7 @@ STATIC_CACHE = f"public, max-age={STATIC_MAX_AGE_SECONDS}, must-revalidate"
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from database import Base, engine
|
||||
from routers import auth_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||
from routers import admin_router, auth_router, birthdays_router, caldav_router, dav_router, google_router, groups_router, homeassistant_router, ical_router, local_router, profile_router, settings_router, users_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
@@ -225,6 +225,44 @@ def _migrate():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Per-setting cross-device sync: value columns for the two newly syncable
|
||||
# device-local prefs, plus the JSON map of which settings sync.
|
||||
for col, ddl in (
|
||||
("cache_months", "ALTER TABLE user_settings ADD COLUMN cache_months INTEGER DEFAULT 3"),
|
||||
("month_view_paged", "ALTER TABLE user_settings ADD COLUMN month_view_paged BOOLEAN DEFAULT 0"),
|
||||
("sync_flags", "ALTER TABLE user_settings ADD COLUMN sync_flags TEXT"),
|
||||
("surface_color", "ALTER TABLE user_settings ADD COLUMN surface_color VARCHAR(7)"),
|
||||
):
|
||||
try:
|
||||
conn.execute(text(ddl))
|
||||
conn.commit()
|
||||
logging.info("Migration: added %s to user_settings", col)
|
||||
except Exception:
|
||||
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).
|
||||
for col, ddl in (
|
||||
("caldav_published", "ALTER TABLE local_calendars ADD COLUMN caldav_published BOOLEAN DEFAULT 0"),
|
||||
@@ -259,6 +297,56 @@ def _migrate():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Birthday calendars: flag + per-calendar "notify N days before".
|
||||
for col, ddl in (
|
||||
("is_birthday", "ALTER TABLE local_calendars ADD COLUMN is_birthday BOOLEAN DEFAULT 0"),
|
||||
("birthday_notify_days_before", "ALTER TABLE local_calendars ADD COLUMN birthday_notify_days_before INTEGER"),
|
||||
):
|
||||
try:
|
||||
conn.execute(text(ddl))
|
||||
conn.commit()
|
||||
logging.info("Migration: added %s to local_calendars", col)
|
||||
except Exception:
|
||||
pass
|
||||
# Birthday events: stable external id (Contacts dedup) + birth year.
|
||||
for col, ddl in (
|
||||
("external_uid", "ALTER TABLE local_events ADD COLUMN external_uid VARCHAR(255)"),
|
||||
("birth_year", "ALTER TABLE local_events ADD COLUMN birth_year INTEGER"),
|
||||
):
|
||||
try:
|
||||
conn.execute(text(ddl))
|
||||
conn.commit()
|
||||
logging.info("Migration: added %s to local_events", col)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# One-time cleanup of duplicate imported events sharing the same
|
||||
# (calendar_id, external_uid) — e.g. birthdays created repeatedly by an
|
||||
# older build without idempotent upsert. Keep the earliest row.
|
||||
# Idempotent: after cleanup there is nothing left to delete.
|
||||
try:
|
||||
conn.execute(text(
|
||||
"DELETE FROM local_events WHERE external_uid IS NOT NULL AND id NOT IN "
|
||||
"(SELECT MIN(id) FROM local_events WHERE external_uid IS NOT NULL "
|
||||
"GROUP BY calendar_id, external_uid)"
|
||||
))
|
||||
conn.commit()
|
||||
logging.info("Migration: de-duplicated local_events by external_uid")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Hard guarantee: the DB itself forbids two events with the same
|
||||
# external_uid in one calendar (imported entries only; NULLs unconstrained).
|
||||
try:
|
||||
conn.execute(text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_local_events_calendar_external "
|
||||
"ON local_events(calendar_id, external_uid) WHERE external_uid IS NOT NULL"
|
||||
))
|
||||
conn.commit()
|
||||
logging.info("Migration: unique index on (calendar_id, external_uid)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_migrate()
|
||||
|
||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
||||
@@ -302,10 +390,12 @@ app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
|
||||
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
|
||||
app.include_router(profile_router.router, prefix="/api/profile", tags=["profile"])
|
||||
app.include_router(local_router.router, prefix="/api/local", tags=["local"])
|
||||
app.include_router(birthdays_router.router, prefix="/api/birthdays", tags=["birthdays"])
|
||||
app.include_router(groups_router.router, prefix="/api/groups", tags=["groups"])
|
||||
app.include_router(ical_router.router, prefix="/api/ical", tags=["ical"])
|
||||
app.include_router(google_router.router, prefix="/api/google", tags=["google"])
|
||||
app.include_router(homeassistant_router.router, prefix="/api/homeassistant", tags=["homeassistant"])
|
||||
app.include_router(admin_router.router, prefix="/api/instance", tags=["instance"])
|
||||
# CalDAV publishing lives at root scope (no /api prefix) and must be registered
|
||||
# before the SPA catch-all so /dav/... isn't swallowed by the index fallback.
|
||||
app.include_router(dav_router.router, tags=["dav"])
|
||||
|
||||
@@ -100,6 +100,18 @@ class UserSettings(Base):
|
||||
text_color = Column(String(7), nullable=True) # Override für --text-1 (NULL = nutze text_contrast)
|
||||
line_color = Column(String(7), nullable=True) # Override für --border (NULL = nutze line_contrast)
|
||||
bg_color = Column(String(7), nullable=True) # Override für --bg-app (NULL = Default)
|
||||
# Surface/sidebar/topbar colour (web sidebar + top bar, iOS top bar).
|
||||
# NULL = derive from bg_color. Device-local by default (not synced).
|
||||
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:
|
||||
# 'hidden' = invisible, 'busy' = anonymous busy block (default).
|
||||
private_event_visibility = Column(String(10), default="busy")
|
||||
@@ -113,10 +125,34 @@ class UserSettings(Base):
|
||||
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)
|
||||
# How many months around the visible range clients preload/cache. Device-local
|
||||
# by default (only shared when its sync flag is on).
|
||||
cache_months = Column(Integer, default=3)
|
||||
# Whether the month view uses horizontal paging (swipe) instead of a vertical
|
||||
# scroll feed. Device-local by default (only shared when its sync flag is on).
|
||||
month_view_paged = Column(Boolean, default=False)
|
||||
# Per-setting cross-device sync overrides as JSON {key: bool}. Absent keys fall
|
||||
# back to settings_router.DEFAULT_SYNC. Account-wide (one map per user); it is
|
||||
# the single authority for which settings each client sends/fetches.
|
||||
sync_flags = Column(Text, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="settings")
|
||||
|
||||
|
||||
class InstanceSettings(Base):
|
||||
"""Server-wide (singleton, id=1) branding + default theme set by an admin.
|
||||
Applies to everyone; a user's own settings still override the default theme."""
|
||||
__tablename__ = "instance_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True) # always 1
|
||||
# JSON {colorKey: "#RRGGBB"} — the instance default theme. Empty/NULL = use the
|
||||
# client's built-in defaults. A user's own colour wins over this.
|
||||
default_theme = Column(Text, nullable=True)
|
||||
# Uploaded branding files (stored under DATA_DIR/branding). NULL = use bundled.
|
||||
logo_filename = Column(String(255), nullable=True)
|
||||
favicon_filename = Column(String(255), nullable=True)
|
||||
|
||||
|
||||
class AppPassword(Base):
|
||||
"""Per-device app-specific password for CalDAV (Basic Auth).
|
||||
|
||||
@@ -145,6 +181,8 @@ class LocalCalendar(Base):
|
||||
name = Column(String(100), nullable=False)
|
||||
color = Column(String(7), default="#34a853")
|
||||
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.
|
||||
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
||||
# CalDAV publishing (opt-in): expose this calendar as a two-way CalDAV
|
||||
@@ -153,6 +191,12 @@ class LocalCalendar(Base):
|
||||
caldav_published = Column(Boolean, default=False, nullable=False)
|
||||
dav_token = Column(String(64), nullable=True, unique=True)
|
||||
dav_ctag = Column(String(32), nullable=True)
|
||||
# Birthday calendar: events are all-day, yearly-recurring; the server adds the
|
||||
# age suffix ("Anna (30)") and an is_birthday flag so clients show a cake icon.
|
||||
is_birthday = Column(Boolean, default=False, nullable=False)
|
||||
# How many days before a birthday to remind (0 = on the day). NULL = no reminder.
|
||||
# Injected as an event reminder on read so the mobile schedulers fire it.
|
||||
birthday_notify_days_before = Column(Integer, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="local_calendars")
|
||||
events = relationship("LocalEvent", back_populates="calendar", cascade="all, delete-orphan")
|
||||
@@ -183,6 +227,11 @@ class LocalEvent(Base):
|
||||
is_private = Column(Boolean, default=False)
|
||||
# CalDAV entity tag — changes on every write so CalDAV clients detect updates.
|
||||
etag = Column(String(32), nullable=True)
|
||||
# Stable external identity for imported entries (e.g. a Contacts birthday keyed
|
||||
# by "contact:<id>"), so a re-sync can mirror the address book without dupes.
|
||||
external_uid = Column(String(255), nullable=True, index=True)
|
||||
# Birth year for birthday events; NULL = year unknown (no age shown).
|
||||
birth_year = Column(Integer, nullable=True)
|
||||
|
||||
calendar = relationship("LocalCalendar", back_populates="events")
|
||||
creator = relationship("User")
|
||||
@@ -197,6 +246,8 @@ class ICalSubscription(Base):
|
||||
url = Column(String(1000), nullable=False)
|
||||
color = Column(String(7), default="#46bdc6")
|
||||
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.
|
||||
reminders_enabled = Column(Boolean, default=True, nullable=False)
|
||||
refresh_minutes = Column(Integer, default=60)
|
||||
@@ -307,13 +358,28 @@ class CalendarShare(Base):
|
||||
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
permission = Column(String(20), default="read") # 'read' | 'read_write'
|
||||
color = Column(String(16), nullable=True) # recipient's own colour; NULL = owner's
|
||||
created_at = Column(String(50), nullable=True) # ISO 8601
|
||||
|
||||
calendar = relationship("LocalCalendar")
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class CalendarColorPref(Base):
|
||||
"""A user's personal colour for a calendar they don't own — works for any
|
||||
way a foreign calendar becomes visible (direct share, group calendar, or a
|
||||
co-member's group-visible calendar). NULL/absent = the owner's colour."""
|
||||
|
||||
__tablename__ = "calendar_color_prefs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("calendar_id", "user_id", name="uq_calendar_color_pref"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
calendar_id = Column(Integer, ForeignKey("local_calendars.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
color = Column(String(16), nullable=False)
|
||||
|
||||
|
||||
class Group(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
@@ -349,6 +415,26 @@ class GroupMember(Base):
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class BirthdaySyncDevice(Base):
|
||||
"""A device that has synced Contacts birthdays into the user's birthday
|
||||
calendar. Powers the web "birthdays come from these devices" list. One row
|
||||
per (user, device); the client sends a stable device_id + human name."""
|
||||
|
||||
__tablename__ = "birthday_sync_devices"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "device_id", name="uq_birthday_sync_device"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
device_id = Column(String(64), nullable=False)
|
||||
device_name = Column(String(120), nullable=False)
|
||||
last_sync = Column(String(50), nullable=True) # ISO 8601
|
||||
count = Column(Integer, default=0)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
|
||||
class GroupCalendar(Base):
|
||||
"""1:1 link between a group and its shared local calendar."""
|
||||
|
||||
|
||||
@@ -163,3 +163,14 @@ def readable_local_calendar_ids(db: Session, user: models.User) -> list[int]:
|
||||
ids.update(c.id for c in co_member_group_visible_calendars(db, user))
|
||||
|
||||
return list(ids)
|
||||
|
||||
|
||||
def color_prefs_for(db: Session, user_id: int) -> dict[int, str]:
|
||||
"""Map calendar_id -> the user's personal colour for calendars they don't
|
||||
own (any sharing path). Empty when the user set no overrides."""
|
||||
return {
|
||||
p.calendar_id: p.color
|
||||
for p in db.query(models.CalendarColorPref).filter(
|
||||
models.CalendarColorPref.user_id == user_id
|
||||
)
|
||||
}
|
||||
|
||||
194
backend/routers/admin_router.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Instance-wide (singleton) settings: an admin-defined default theme plus a
|
||||
custom logo and favicon. The GET endpoint is public (needed on the login screen,
|
||||
before auth); all writes require admin. Branding files are stored under
|
||||
DATA_DIR/branding and served via FileResponse, mirroring the avatar pattern."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from auth import get_current_admin
|
||||
from database import DATA_DIR, get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
BRANDING_DIR = DATA_DIR / "branding"
|
||||
BRANDING_DIR.mkdir(parents=True, exist_ok=True)
|
||||
MAX_BRANDING_SIZE = 5 * 1024 * 1024 # 5 MB
|
||||
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
|
||||
# Colour keys an admin may set as the instance default theme. Keep in sync with
|
||||
# the client's DEFAULT_COLORS / DEFAULT_SYNC colour keys (settings-sync.js).
|
||||
THEME_COLOR_KEYS = {
|
||||
"primary_color", "accent_color", "today_color", "text_color", "bg_color",
|
||||
"line_color", "surface_color", "month_divider_color", "month_label_color",
|
||||
"hover_highlight_color", "icon_inactive_color", "icon_active_color",
|
||||
"day_hover_color", "day_selected_color", "day_bg_color", "today_bg_color",
|
||||
}
|
||||
HEX_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
def _get_or_create(db: Session) -> models.InstanceSettings:
|
||||
inst = db.query(models.InstanceSettings).filter(models.InstanceSettings.id == 1).first()
|
||||
if not inst:
|
||||
inst = models.InstanceSettings(id=1)
|
||||
db.add(inst)
|
||||
db.commit()
|
||||
db.refresh(inst)
|
||||
return inst
|
||||
|
||||
|
||||
def _mtime(filename: Optional[str]) -> int:
|
||||
if not filename:
|
||||
return 0
|
||||
p = BRANDING_DIR / filename
|
||||
try:
|
||||
return int(p.stat().st_mtime)
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def _public_dict(inst: models.InstanceSettings) -> dict:
|
||||
theme = {}
|
||||
if inst.default_theme:
|
||||
try:
|
||||
theme = json.loads(inst.default_theme) or {}
|
||||
except (ValueError, TypeError):
|
||||
theme = {}
|
||||
has_logo = bool(inst.logo_filename) and (BRANDING_DIR / (inst.logo_filename or "")).exists()
|
||||
has_favicon = bool(inst.favicon_filename) and (BRANDING_DIR / (inst.favicon_filename or "")).exists()
|
||||
return {
|
||||
"default_theme": theme,
|
||||
"has_logo": has_logo,
|
||||
"has_favicon": has_favicon,
|
||||
# Cache-busted URLs so a freshly uploaded asset is fetched immediately.
|
||||
"logo_url": f"/api/instance/logo?v={_mtime(inst.logo_filename)}" if has_logo else None,
|
||||
"favicon_url": f"/api/instance/favicon?v={_mtime(inst.favicon_filename)}" if has_favicon else None,
|
||||
}
|
||||
|
||||
|
||||
# ── Public read ───────────────────────────────────────────
|
||||
@router.get("/")
|
||||
def get_instance(db: Session = Depends(get_db)):
|
||||
return _public_dict(_get_or_create(db))
|
||||
|
||||
|
||||
@router.get("/logo")
|
||||
def get_logo(db: Session = Depends(get_db)):
|
||||
inst = _get_or_create(db)
|
||||
if not inst.logo_filename:
|
||||
raise HTTPException(404, "No logo")
|
||||
path = BRANDING_DIR / inst.logo_filename
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "No logo")
|
||||
return FileResponse(str(path), headers={"Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
@router.get("/favicon")
|
||||
def get_favicon(db: Session = Depends(get_db)):
|
||||
inst = _get_or_create(db)
|
||||
if not inst.favicon_filename:
|
||||
raise HTTPException(404, "No favicon")
|
||||
path = BRANDING_DIR / inst.favicon_filename
|
||||
if not path.exists():
|
||||
raise HTTPException(404, "No favicon")
|
||||
return FileResponse(str(path), headers={"Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
# ── Admin writes ──────────────────────────────────────────
|
||||
class ThemeUpdate(BaseModel):
|
||||
default_theme: dict # {colorKey: "#RRGGBB"}; empty = reset to built-in
|
||||
|
||||
|
||||
@router.put("/theme")
|
||||
def set_default_theme(
|
||||
data: ThemeUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: models.User = Depends(get_current_admin),
|
||||
):
|
||||
# Keep only known colour keys with valid hex values.
|
||||
clean = {
|
||||
k: v.upper()
|
||||
for k, v in (data.default_theme or {}).items()
|
||||
if k in THEME_COLOR_KEYS and isinstance(v, str) and HEX_RE.match(v)
|
||||
}
|
||||
inst = _get_or_create(db)
|
||||
inst.default_theme = json.dumps(clean) if clean else None
|
||||
db.commit()
|
||||
return {"ok": True, "default_theme": clean}
|
||||
|
||||
|
||||
async def _save_branding(file: UploadFile, kind: str) -> str:
|
||||
"""Validate + normalise an uploaded image and store it. Returns the filename."""
|
||||
if file.content_type not in ALLOWED_TYPES:
|
||||
raise HTTPException(400, "Only JPEG, PNG or WebP allowed")
|
||||
raw = await file.read()
|
||||
if len(raw) > MAX_BRANDING_SIZE:
|
||||
raise HTTPException(400, "File too large (max 5 MB)")
|
||||
try:
|
||||
img = Image.open(io.BytesIO(raw)).convert("RGBA")
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid image")
|
||||
if kind == "favicon":
|
||||
img = img.resize((128, 128), Image.LANCZOS)
|
||||
else: # logo: keep aspect ratio, cap the longest edge at 256px
|
||||
img.thumbnail((256, 256), Image.LANCZOS)
|
||||
filename = f"{kind}.png"
|
||||
img.save(str(BRANDING_DIR / filename), "PNG")
|
||||
return filename
|
||||
|
||||
|
||||
@router.post("/logo")
|
||||
async def upload_logo(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
admin: models.User = Depends(get_current_admin),
|
||||
):
|
||||
inst = _get_or_create(db)
|
||||
inst.logo_filename = await _save_branding(file, "logo")
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/logo")
|
||||
def delete_logo(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)):
|
||||
inst = _get_or_create(db)
|
||||
if inst.logo_filename:
|
||||
p = BRANDING_DIR / inst.logo_filename
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
inst.logo_filename = None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/favicon")
|
||||
async def upload_favicon(
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
admin: models.User = Depends(get_current_admin),
|
||||
):
|
||||
inst = _get_or_create(db)
|
||||
inst.favicon_filename = await _save_branding(file, "favicon")
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/favicon")
|
||||
def delete_favicon(db: Session = Depends(get_db), admin: models.User = Depends(get_current_admin)):
|
||||
inst = _get_or_create(db)
|
||||
if inst.favicon_filename:
|
||||
p = BRANDING_DIR / inst.favicon_filename
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
inst.favicon_filename = None
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
77
backend/routers/birthdays_router.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Birthday sync device tracking.
|
||||
|
||||
The iOS app reports, after each Contacts birthday sync, which device it was and
|
||||
how many birthdays it manages. The web shows this as a "birthdays come from
|
||||
these devices" list. Birthday events themselves are ordinary local events
|
||||
(see local_router); this router only tracks the sync sources.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SyncReport(BaseModel):
|
||||
device_id: str
|
||||
device_name: str
|
||||
count: int = 0
|
||||
|
||||
|
||||
@router.post("/sync-report")
|
||||
def report_sync(
|
||||
data: SyncReport,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Upsert the (user, device) sync record after a Contacts birthday sync."""
|
||||
row = (
|
||||
db.query(models.BirthdaySyncDevice)
|
||||
.filter(
|
||||
models.BirthdaySyncDevice.user_id == current_user.id,
|
||||
models.BirthdaySyncDevice.device_id == data.device_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
name = (data.device_name or "Gerät")[:120]
|
||||
if row is None:
|
||||
db.add(models.BirthdaySyncDevice(
|
||||
user_id=current_user.id, device_id=data.device_id,
|
||||
device_name=name, last_sync=now, count=data.count,
|
||||
))
|
||||
else:
|
||||
row.device_name = name
|
||||
row.last_sync = now
|
||||
row.count = data.count
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/devices")
|
||||
def list_devices(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
rows = (
|
||||
db.query(models.BirthdaySyncDevice)
|
||||
.filter(models.BirthdaySyncDevice.user_id == current_user.id)
|
||||
.order_by(models.BirthdaySyncDevice.last_sync.desc())
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"device_id": r.device_id,
|
||||
"device_name": r.device_name,
|
||||
"last_sync": r.last_sync,
|
||||
"count": r.count,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -367,6 +367,8 @@ def get_events(
|
||||
models.GroupCalendar.calendar_id.in_(readable_ids)
|
||||
)
|
||||
} if readable_ids else set()
|
||||
# Per-user colour overrides for calendars the user doesn't own (any share path).
|
||||
color_prefs = permissions.color_prefs_for(db, current_user.id)
|
||||
# Cache each owner's private-event visibility (one lookup per owner, not per event).
|
||||
vis_cache: dict = {}
|
||||
|
||||
@@ -384,7 +386,7 @@ def get_events(
|
||||
share = shares_by_cal.get(local_cal.id) if is_shared_personal else None
|
||||
shared_owner_name = name_cache.get(local_cal.user_id) if is_shared_personal else None
|
||||
shared_read_only = is_shared_personal and (share.permission if share else None) != "read_write"
|
||||
shared_color = share.color if share else None
|
||||
shared_color = color_prefs.get(local_cal.id)
|
||||
local_events = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(
|
||||
|
||||
@@ -380,6 +380,9 @@ def combined_events(
|
||||
def emit_calendar(cal: models.LocalCalendar, owner_id: int, is_group: bool):
|
||||
owner_user = name_cache.get(owner_id)
|
||||
owner = {"id": owner_id, "display_name": owner_user}
|
||||
# Editable by the requester iff it's the shared group calendar (all members
|
||||
# may write) or the requester's own calendar; everyone else's is read-only.
|
||||
read_only = not (is_group or owner_id == current_user.id)
|
||||
events = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(
|
||||
@@ -405,9 +408,9 @@ def combined_events(
|
||||
creator = {"id": None, "display_name": f"{ev.creator_name_external} (importiert)"}
|
||||
|
||||
if ev.rrule:
|
||||
built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group)
|
||||
built = expand_recurring_local(ev, cal, start_dt, end_dt, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)
|
||||
else:
|
||||
built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group)]
|
||||
built = [build_local_event_dict(ev, cal, rrule=None, creator=creator, owner=owner, is_group_event=is_group, read_only=read_only)]
|
||||
|
||||
for b in built:
|
||||
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
|
||||
@@ -417,10 +420,13 @@ def combined_events(
|
||||
b["display_color"] = group_cal_color if is_group else member_color.get(owner_id)
|
||||
# Decorated title (group icon / owner name) computed server-side
|
||||
# so all clients render identically; raw `title` kept for editing.
|
||||
b["display_title"] = _decorate_title(
|
||||
b.get("title", ""), is_group=is_group, creator=b.get("creator"),
|
||||
owner=owner, me_id=current_user.id,
|
||||
)
|
||||
# A birthday event already carries an age display_title from
|
||||
# build_local_event_dict — keep it rather than clobber the age.
|
||||
if not b.get("is_birthday"):
|
||||
b["display_title"] = _decorate_title(
|
||||
b.get("title", ""), is_group=is_group, creator=b.get("creator"),
|
||||
owner=owner, me_id=current_user.id,
|
||||
)
|
||||
all_events.append(b)
|
||||
|
||||
# Each member shares exactly one calendar into their groups, chosen in their
|
||||
|
||||
@@ -31,6 +31,7 @@ class SubscriptionUpdate(BaseModel):
|
||||
url: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
sidebar_hidden: Optional[bool] = None
|
||||
refresh_minutes: Optional[int] = None
|
||||
reminders_enabled: Optional[bool] = None
|
||||
|
||||
@@ -42,6 +43,7 @@ def _sub_dict(sub: models.ICalSubscription) -> dict:
|
||||
"url": sub.url,
|
||||
"color": sub.color,
|
||||
"enabled": sub.enabled,
|
||||
"sidebar_hidden": bool(sub.sidebar_hidden),
|
||||
"reminders_enabled": bool(sub.reminders_enabled),
|
||||
"refresh_minutes": sub.refresh_minutes,
|
||||
"last_fetched": sub.last_fetched.isoformat() if sub.last_fetched else None,
|
||||
@@ -273,6 +275,8 @@ def update_subscription(
|
||||
sub.color = data.color
|
||||
if data.enabled is not None:
|
||||
sub.enabled = data.enabled
|
||||
if data.sidebar_hidden is not None:
|
||||
sub.sidebar_hidden = data.sidebar_hidden
|
||||
if data.refresh_minutes is not None:
|
||||
sub.refresh_minutes = data.refresh_minutes
|
||||
if data.reminders_enabled is not None:
|
||||
|
||||
@@ -25,14 +25,19 @@ def _now_iso() -> str:
|
||||
class CalendarCreate(BaseModel):
|
||||
name: str
|
||||
color: str = "#34a853"
|
||||
is_birthday: bool = False
|
||||
birthday_notify_days_before: Optional[int] = None
|
||||
|
||||
|
||||
class CalendarUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
enabled: Optional[bool] = None
|
||||
sidebar_hidden: Optional[bool] = None
|
||||
reminders_enabled: Optional[bool] = None
|
||||
caldav_published: Optional[bool] = None
|
||||
is_birthday: Optional[bool] = None
|
||||
birthday_notify_days_before: Optional[int] = None
|
||||
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
@@ -47,6 +52,8 @@ class EventCreate(BaseModel):
|
||||
rrule: Optional[str] = None
|
||||
private: bool = False
|
||||
reminders: Optional[List[int]] = None # minutes before start (0 = at start)
|
||||
external_uid: Optional[str] = None # stable id for imported entries (Contacts dedup)
|
||||
birth_year: Optional[int] = None # birthday events; NULL = year unknown
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
@@ -61,6 +68,8 @@ class EventUpdate(BaseModel):
|
||||
exdate: Optional[str] = None
|
||||
private: Optional[bool] = None
|
||||
reminders: Optional[List[int]] = None
|
||||
external_uid: Optional[str] = None
|
||||
birth_year: Optional[int] = None
|
||||
|
||||
|
||||
class ShareCreate(BaseModel):
|
||||
@@ -74,12 +83,17 @@ def _cal_dict(cal: models.LocalCalendar, *, owned: bool = True,
|
||||
request: Optional[Request] = None) -> dict:
|
||||
d = {
|
||||
"id": cal.id,
|
||||
"name": cal.name,
|
||||
# A shared calendar is labelled by the person/group it comes from — the
|
||||
# owner's real calendar name must never reach recipients.
|
||||
"name": shared_by if (not owned and shared_by is not None) else cal.name,
|
||||
# A recipient's own colour for a shared calendar wins over the owner's.
|
||||
"color": color_override or cal.color,
|
||||
"enabled": cal.enabled,
|
||||
"sidebar_hidden": bool(cal.sidebar_hidden),
|
||||
"reminders_enabled": bool(cal.reminders_enabled),
|
||||
"caldav_published": bool(cal.caldav_published),
|
||||
"is_birthday": bool(cal.is_birthday),
|
||||
"birthday_notify_days_before": cal.birthday_notify_days_before,
|
||||
"type": "local",
|
||||
"owned": owned,
|
||||
}
|
||||
@@ -120,6 +134,9 @@ def list_calendars(
|
||||
)
|
||||
}
|
||||
|
||||
# Per-user colour overrides for calendars the user doesn't own (any sharing path).
|
||||
color_prefs = permissions.color_prefs_for(db, current_user.id)
|
||||
|
||||
# Own calendars
|
||||
own = (
|
||||
db.query(models.LocalCalendar)
|
||||
@@ -152,7 +169,7 @@ def list_calendars(
|
||||
cal, owned=False,
|
||||
shared_by=(owner.display_name or owner.username) if owner else None,
|
||||
permission=share.permission,
|
||||
color_override=share.color,
|
||||
color_override=color_prefs.get(cal.id),
|
||||
)
|
||||
if cal.id in group_cal_map:
|
||||
d["group"] = True
|
||||
@@ -167,7 +184,8 @@ def list_calendars(
|
||||
if not cal:
|
||||
continue
|
||||
seen_ids.add(cal_id)
|
||||
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write")
|
||||
d = _cal_dict(cal, owned=False, shared_by=group_name, permission="read_write",
|
||||
color_override=color_prefs.get(cal.id))
|
||||
d["group"] = True
|
||||
result.append(d)
|
||||
|
||||
@@ -183,6 +201,7 @@ def list_calendars(
|
||||
cal, owned=False,
|
||||
shared_by=(owner.display_name or owner.username) if owner else None,
|
||||
permission="read",
|
||||
color_override=color_prefs.get(cal.id),
|
||||
request=request,
|
||||
)
|
||||
d["group_shared"] = True
|
||||
@@ -196,10 +215,27 @@ def create_calendar(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
# Only ONE birthday calendar per account — hard server guarantee. If the
|
||||
# user already has one, "create birthday calendar" is idempotent: return the
|
||||
# existing one instead of creating a second.
|
||||
if data.is_birthday:
|
||||
existing = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
models.LocalCalendar.is_birthday == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
return _cal_dict(existing)
|
||||
|
||||
cal = models.LocalCalendar(
|
||||
user_id=current_user.id,
|
||||
name=data.name,
|
||||
color=data.color,
|
||||
is_birthday=data.is_birthday,
|
||||
birthday_notify_days_before=data.birthday_notify_days_before,
|
||||
)
|
||||
db.add(cal)
|
||||
db.commit()
|
||||
@@ -231,8 +267,31 @@ def update_calendar(
|
||||
cal.color = data.color
|
||||
if data.enabled is not None:
|
||||
cal.enabled = data.enabled
|
||||
if data.sidebar_hidden is not None:
|
||||
cal.sidebar_hidden = data.sidebar_hidden
|
||||
if data.reminders_enabled is not None:
|
||||
cal.reminders_enabled = data.reminders_enabled
|
||||
if data.is_birthday is not None:
|
||||
# Never let a second calendar be marked as the birthday calendar.
|
||||
if data.is_birthday and not cal.is_birthday:
|
||||
other = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
models.LocalCalendar.is_birthday == True,
|
||||
models.LocalCalendar.id != cal.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if other is not None:
|
||||
raise HTTPException(422, "A birthday calendar already exists")
|
||||
cal.is_birthday = data.is_birthday
|
||||
if data.birthday_notify_days_before is not None:
|
||||
# -1 is the client's sentinel for "clear the reminder" (JSON has no way to
|
||||
# send SQL NULL through an Optional that also means "unchanged").
|
||||
cal.birthday_notify_days_before = (
|
||||
None if data.birthday_notify_days_before < 0 else data.birthday_notify_days_before
|
||||
)
|
||||
if data.caldav_published is not None:
|
||||
cal.caldav_published = data.caldav_published
|
||||
if data.caldav_published:
|
||||
@@ -261,17 +320,30 @@ def set_calendar_color(
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Set a calendar's colour. The owner changes the calendar's colour for
|
||||
everyone; a share recipient sets only their OWN per-user colour (stored on
|
||||
the share) without touching the owner's. Recipients may recolour but never
|
||||
rename a shared calendar."""
|
||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id)
|
||||
everyone; anyone else who can see the calendar (direct share, group calendar,
|
||||
or a co-member's group-visible calendar) sets only their OWN per-user colour.
|
||||
Recipients may recolour but never rename a shared calendar."""
|
||||
cal = db.query(models.LocalCalendar).filter(models.LocalCalendar.id == calendar_id).first()
|
||||
if cal is None:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
if cal.user_id == current_user.id:
|
||||
cal.color = data.color
|
||||
else:
|
||||
share = permissions._share_for(db, calendar_id, current_user.id)
|
||||
if share is None:
|
||||
raise HTTPException(403, "Only the owner or a share recipient can set the colour")
|
||||
share.color = data.color
|
||||
if calendar_id not in permissions.readable_local_calendar_ids(db, current_user):
|
||||
raise HTTPException(403, "You cannot access this calendar")
|
||||
pref = (
|
||||
db.query(models.CalendarColorPref)
|
||||
.filter(
|
||||
models.CalendarColorPref.calendar_id == calendar_id,
|
||||
models.CalendarColorPref.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if pref:
|
||||
pref.color = data.color
|
||||
else:
|
||||
db.add(models.CalendarColorPref(
|
||||
calendar_id=calendar_id, user_id=current_user.id, color=data.color))
|
||||
db.commit()
|
||||
return {"ok": True, "color": data.color}
|
||||
|
||||
@@ -337,22 +409,55 @@ def create_event(
|
||||
db, current_user, data.calendar_id, require_write=True
|
||||
)
|
||||
|
||||
ev = models.LocalEvent(
|
||||
calendar_id=cal.id,
|
||||
uid=str(uuid.uuid4()),
|
||||
title=data.title,
|
||||
start=data.start,
|
||||
end=data.end,
|
||||
all_day=data.allDay,
|
||||
location=data.location,
|
||||
description=data.description,
|
||||
color=data.color,
|
||||
rrule=data.rrule,
|
||||
is_private=data.private,
|
||||
reminders=(",".join(str(m) for m in data.reminders) if data.reminders else None),
|
||||
creator_id=current_user.id, # server-side, never from the client
|
||||
)
|
||||
db.add(ev)
|
||||
# Idempotent on external_uid: repeated syncs of the same contact (stable
|
||||
# "contact:<deviceId>:<contactId>") must NEVER create duplicates. If a row
|
||||
# with this external_uid already exists in this calendar, update it in place
|
||||
# instead of inserting a new one. Enforced server-side, so the client can
|
||||
# never duplicate — even without the reconcile read.
|
||||
existing = None
|
||||
if data.external_uid:
|
||||
existing = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(
|
||||
models.LocalEvent.calendar_id == cal.id,
|
||||
models.LocalEvent.external_uid == data.external_uid,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
|
||||
if existing is not None:
|
||||
ev = existing
|
||||
ev.title = data.title
|
||||
ev.start = data.start
|
||||
ev.end = data.end
|
||||
ev.all_day = data.allDay
|
||||
ev.location = data.location
|
||||
ev.description = data.description
|
||||
ev.color = data.color
|
||||
ev.rrule = data.rrule
|
||||
ev.is_private = data.private
|
||||
ev.reminders = reminders
|
||||
ev.birth_year = data.birth_year
|
||||
else:
|
||||
ev = models.LocalEvent(
|
||||
calendar_id=cal.id,
|
||||
uid=str(uuid.uuid4()),
|
||||
title=data.title,
|
||||
start=data.start,
|
||||
end=data.end,
|
||||
all_day=data.allDay,
|
||||
location=data.location,
|
||||
description=data.description,
|
||||
color=data.color,
|
||||
rrule=data.rrule,
|
||||
is_private=data.private,
|
||||
reminders=reminders,
|
||||
external_uid=data.external_uid,
|
||||
birth_year=data.birth_year,
|
||||
creator_id=current_user.id, # server-side, never from the client
|
||||
)
|
||||
db.add(ev)
|
||||
dav_util.bump_dav(cal, ev)
|
||||
db.commit()
|
||||
db.refresh(ev)
|
||||
@@ -402,6 +507,11 @@ def update_event(
|
||||
ev.exdate = ",".join(dates)
|
||||
if data.reminders is not None:
|
||||
ev.reminders = ",".join(str(m) for m in data.reminders) if data.reminders else None
|
||||
if data.external_uid is not None:
|
||||
ev.external_uid = data.external_uid
|
||||
if data.birth_year is not None:
|
||||
# -1 is the client's sentinel for "clear" (year became unknown).
|
||||
ev.birth_year = None if data.birth_year < 0 else data.birth_year
|
||||
dav_util.bump_dav(ev.calendar, ev)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -420,6 +530,42 @@ def delete_event(
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/calendars/{calendar_id}/birthdays")
|
||||
def list_birthday_entries(
|
||||
calendar_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
"""Raw (unexpanded) rows of a birthday calendar so a client importer can
|
||||
reconcile against the address book by ``external_uid``. Contact-sourced rows
|
||||
carry an ``external_uid``; manually added birthdays have ``None`` and must be
|
||||
left untouched by the importer."""
|
||||
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
|
||||
events = (
|
||||
db.query(models.LocalEvent)
|
||||
.filter(models.LocalEvent.calendar_id == cal.id)
|
||||
.all()
|
||||
)
|
||||
out = []
|
||||
for ev in events:
|
||||
month = day = None
|
||||
try:
|
||||
parts = (ev.start or "")[:10].split("-")
|
||||
if len(parts) == 3:
|
||||
month, day = int(parts[1]), int(parts[2])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
out.append({
|
||||
"uid": ev.uid,
|
||||
"external_uid": ev.external_uid,
|
||||
"title": ev.title,
|
||||
"month": month,
|
||||
"day": day,
|
||||
"birth_year": ev.birth_year,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ── Sharing (owner only) ──────────────────────────────────
|
||||
|
||||
@router.get("/calendars/{calendar_id}/shares")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -11,6 +12,56 @@ from database import get_db
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Which settings can sync across a user's devices, and the default flag applied to
|
||||
# an existing/new account until the user overrides it. This map is the single
|
||||
# authority: GET returns the fully-resolved flags so no client duplicates default
|
||||
# logic. Rollout rule: settings that already lived on the server default to True;
|
||||
# the four newly-syncable device-local prefs default to False.
|
||||
DEFAULT_SYNC = {
|
||||
"default_view": True,
|
||||
"week_start_day": True,
|
||||
"dim_past_events": True,
|
||||
"hour_height": True,
|
||||
"primary_color": True,
|
||||
"accent_color": True,
|
||||
"today_color": True,
|
||||
"text_color": True,
|
||||
"line_color": True,
|
||||
"bg_color": True,
|
||||
"month_divider_color": True,
|
||||
"month_label_color": True,
|
||||
"default_event_duration_minutes": True,
|
||||
"default_reminder_minutes": True,
|
||||
"language": False,
|
||||
"share_calendar_icon": False,
|
||||
"cache_months": False,
|
||||
"month_view_paged": 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,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_sync_flags(s: models.UserSettings) -> dict:
|
||||
"""Fully-resolved {key: bool} for every syncable setting: stored overrides on
|
||||
top of DEFAULT_SYNC, junk keys dropped."""
|
||||
stored = {}
|
||||
if s.sync_flags:
|
||||
try:
|
||||
stored = json.loads(s.sync_flags) or {}
|
||||
except (ValueError, TypeError):
|
||||
stored = {}
|
||||
return {
|
||||
key: bool(stored[key]) if key in stored else default
|
||||
for key, default in DEFAULT_SYNC.items()
|
||||
}
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
default_view: Optional[str] = None
|
||||
week_start_day: Optional[str] = None
|
||||
@@ -27,11 +78,22 @@ class SettingsUpdate(BaseModel):
|
||||
text_color: Optional[str] = None
|
||||
line_color: Optional[str] = None
|
||||
bg_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
|
||||
group_visible_calendar_id: Optional[int] = None
|
||||
default_reminder_minutes: Optional[int] = None # null = off
|
||||
default_event_duration_minutes: Optional[int] = None
|
||||
share_calendar_icon: Optional[str] = None
|
||||
cache_months: Optional[int] = None
|
||||
month_view_paged: Optional[bool] = None
|
||||
sync_flags: Optional[dict] = None # partial {key: bool}, merged into stored map
|
||||
|
||||
|
||||
def _settings_dict(s: models.UserSettings) -> dict:
|
||||
@@ -51,11 +113,22 @@ def _settings_dict(s: models.UserSettings) -> dict:
|
||||
"text_color": s.text_color,
|
||||
"line_color": s.line_color,
|
||||
"bg_color": s.bg_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",
|
||||
"group_visible_calendar_id": s.group_visible_calendar_id,
|
||||
"default_reminder_minutes": s.default_reminder_minutes,
|
||||
"default_event_duration_minutes": s.default_event_duration_minutes or 60,
|
||||
"share_calendar_icon": s.share_calendar_icon,
|
||||
"cache_months": s.cache_months or 3,
|
||||
"month_view_paged": bool(s.month_view_paged),
|
||||
"sync_flags": _resolve_sync_flags(s),
|
||||
}
|
||||
|
||||
|
||||
@@ -95,11 +168,42 @@ def update_settings(
|
||||
if data.private_event_visibility is not None and data.private_event_visibility not in ("hidden", "busy"):
|
||||
raise HTTPException(422, "private_event_visibility must be 'hidden' or 'busy'")
|
||||
|
||||
# A birthday calendar must never become the group-visible ("personal")
|
||||
# calendar — it may be shared directly, but not stand in as your calendar in
|
||||
# group views. Clients filter it out of the picker; this is the safety net.
|
||||
if data.group_visible_calendar_id:
|
||||
bcal = (
|
||||
db.query(models.LocalCalendar)
|
||||
.filter(
|
||||
models.LocalCalendar.id == data.group_visible_calendar_id,
|
||||
models.LocalCalendar.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if bcal is not None and bcal.is_birthday:
|
||||
raise HTTPException(422, "A birthday calendar can't be your group-visible calendar")
|
||||
|
||||
# For these three override colours, an explicit null is meaningful
|
||||
# ("reset to default") and must be persisted as NULL. All other fields
|
||||
# keep the previous behaviour where a null/missing value is ignored.
|
||||
NULLABLE_OVERRIDES = {"text_color", "line_color", "bg_color", "group_visible_calendar_id", "default_reminder_minutes", "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)
|
||||
|
||||
# Merge sync-flag overrides into the stored account-wide JSON map. Only known
|
||||
# syncable keys are kept; a partial map leaves untouched flags as they were.
|
||||
if "sync_flags" in update_data:
|
||||
incoming = update_data.pop("sync_flags") or {}
|
||||
current = {}
|
||||
if settings.sync_flags:
|
||||
try:
|
||||
current = json.loads(settings.sync_flags) or {}
|
||||
except (ValueError, TypeError):
|
||||
current = {}
|
||||
for key, val in incoming.items():
|
||||
if key in DEFAULT_SYNC:
|
||||
current[key] = bool(val)
|
||||
settings.sync_flags = json.dumps(current)
|
||||
|
||||
for field, value in update_data.items():
|
||||
if field in NULLABLE_OVERRIDES:
|
||||
setattr(settings, field, value or None)
|
||||
|
||||
@@ -23,6 +23,10 @@ class ChangePasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class SetAdminRequest(BaseModel):
|
||||
is_admin: bool
|
||||
|
||||
|
||||
def _user_dict(u: models.User) -> dict:
|
||||
return {
|
||||
"id": u.id,
|
||||
@@ -102,6 +106,28 @@ def delete_user(
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/{user_id}/admin")
|
||||
def set_admin(
|
||||
user_id: int,
|
||||
req: SetAdminRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_admin),
|
||||
):
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(400, "Cannot change your own admin status")
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
# Never leave the instance without an admin.
|
||||
if user.is_admin and not req.is_admin:
|
||||
admin_count = db.query(models.User).filter(models.User.is_admin == True).count() # noqa: E712
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(400, "At least one admin must remain")
|
||||
user.is_admin = req.is_admin
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/{user_id}/password")
|
||||
def change_password(
|
||||
user_id: int,
|
||||
|
||||
@@ -484,3 +484,31 @@ def test_directory_hidden_excludes_from_picker_but_not_admin(client):
|
||||
# Admin user management still lists the hidden user.
|
||||
assert any(u["id"] == b_id for u in
|
||||
client.get("/api/users/", headers=auth(admin)).json())
|
||||
|
||||
|
||||
def test_combined_view_read_only_for_other_members(client):
|
||||
"""In the group combined view, events I may not edit carry read_only=True:
|
||||
other members' calendars are read-only; the group calendar + my own aren't."""
|
||||
admin = register_admin(client)
|
||||
b_id, b_tok = create_user(client, admin, "bob")
|
||||
group = client.post("/api/groups/", headers=auth(admin),
|
||||
json={"name": "Team", "member_ids": [b_id]}).json()
|
||||
gid = group["id"]
|
||||
gcal = group["group_calendar_id"]
|
||||
|
||||
b_cal = _make_calendar(client, b_tok, "Bobs Kalender")
|
||||
client.put("/api/settings/", headers=auth(b_tok), json={"group_visible_calendar_id": b_cal})
|
||||
_make_event(client, b_tok, b_cal, "Bobs Termin")
|
||||
_make_event(client, admin, gcal, "Gruppentermin")
|
||||
|
||||
# As admin: bob's event is read-only; the group calendar is editable.
|
||||
by = {e["title"]: e for e in
|
||||
client.get(f"/api/groups/{gid}/combined", headers=auth(admin), params=RANGE).json()["events"]}
|
||||
assert by["Bobs Termin"].get("read_only") is True
|
||||
assert by["Gruppentermin"].get("read_only") is not True
|
||||
|
||||
# As bob: his own event and the group calendar are both editable.
|
||||
by_b = {e["title"]: e for e in
|
||||
client.get(f"/api/groups/{gid}/combined", headers=auth(b_tok), params=RANGE).json()["events"]}
|
||||
assert by_b["Bobs Termin"].get("read_only") is not True
|
||||
assert by_b["Gruppentermin"].get("read_only") is not True
|
||||
|
||||
@@ -23,6 +23,17 @@
|
||||
--border-light: #242438;
|
||||
--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;
|
||||
--sidebar-w: 256px;
|
||||
--shadow: 0 2px 12px rgba(0,0,0,.45);
|
||||
@@ -52,6 +63,9 @@ input, textarea, [contenteditable="true"], .selectable {
|
||||
-webkit-user-select: text; user-select: text;
|
||||
}
|
||||
a { color: var(--primary); text-decoration: none; }
|
||||
/* Version im Impressum verlinkt aufs Gitea-Repo — dezent, erst beim Hover als Link erkennbar */
|
||||
.impressum-version-link { color: var(--text-3); text-decoration: none; transition: color .15s ease; }
|
||||
.impressum-version-link:hover { color: var(--primary); text-decoration: underline; }
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--scrollbar); border-radius: 3px; }
|
||||
@@ -165,6 +179,28 @@ a { color: var(--primary); text-decoration: none; }
|
||||
}
|
||||
.btn-fab:active { transform: translateY(0) scale(.985); }
|
||||
|
||||
/* Split create button: one seamless pill — main "Erstellen" action on the left,
|
||||
a caret section on the right that opens a small menu (new event / new birthday). */
|
||||
.create-split {
|
||||
display: flex; align-items: stretch; margin: 16px 12px 8px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--primary) 35%, transparent);
|
||||
}
|
||||
.create-split .btn-fab { margin: 0; box-shadow: none; border-radius: 0; }
|
||||
.create-split .btn-fab:hover { transform: none; box-shadow: none; filter: brightness(1.08); }
|
||||
.create-split .create-main { flex: 1; justify-content: center; border-radius: 999px 0 0 999px; }
|
||||
.create-split .create-caret {
|
||||
flex: 0 0 auto; padding: 12px 14px; border-radius: 0 999px 999px 0;
|
||||
border-left: 1px solid color-mix(in srgb, #fff 25%, transparent);
|
||||
}
|
||||
.create-split .create-caret svg { width: 18px; height: 18px; }
|
||||
.create-menu { left: 0; right: auto; min-width: 200px; }
|
||||
|
||||
/* Birthday modal: day / month / year row */
|
||||
.birthday-date-row { display: flex; gap: 8px; }
|
||||
.birthday-date-row select { flex: 1; }
|
||||
.birthday-date-row #birthday-year { width: 90px; flex: 0 0 auto; }
|
||||
|
||||
/* Circular icon buttons (topbar nav, modal close, etc.) */
|
||||
.icon-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
@@ -179,7 +215,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
transform .1s ease;
|
||||
}
|
||||
.icon-btn svg { width: 20px; height: 20px; fill: currentColor; }
|
||||
.icon-btn:hover { background: var(--bg-hover); color: var(--text-1); }
|
||||
.icon-btn:hover { background: var(--hover-highlight); color: var(--text-1); }
|
||||
.icon-btn:active { transform: scale(.92); }
|
||||
.icon-btn:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
@@ -288,7 +324,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
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); }
|
||||
|
||||
/* ── Date/time input dark mode ──────────────────────────── */
|
||||
@@ -459,7 +495,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
transition: background var(--transition), color var(--transition);
|
||||
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); }
|
||||
|
||||
.user-menu-wrapper { position: relative; }
|
||||
@@ -488,7 +524,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
background: none; color: var(--text-2); font-size: 13px;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -533,7 +569,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
.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-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-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; }
|
||||
@@ -544,13 +580,13 @@ a { color: var(--primary); text-decoration: none; }
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
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.today {
|
||||
background: var(--today-color);
|
||||
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 {
|
||||
content: ''; position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%);
|
||||
width: 4px; height: 4px; border-radius: 50%; background: var(--primary);
|
||||
@@ -577,7 +613,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
text-align: left; font-size: 13px; color: var(--text-1);
|
||||
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 {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 16px; cursor: pointer;
|
||||
@@ -585,7 +621,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
border-radius: 0 20px 20px 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.cal-item:hover { background: var(--bg-hover); }
|
||||
.cal-item:hover { background: var(--hover-highlight); }
|
||||
.cal-item-dot {
|
||||
width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; cursor: pointer;
|
||||
}
|
||||
@@ -602,11 +638,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; }
|
||||
/* Fixed min-height so 28px mini-btns never change row height when they appear. */
|
||||
.cal-item { position: relative; min-height: 40px; }
|
||||
.cal-item-bell { display: none; flex-shrink: 0; }
|
||||
/* 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-bell { display: inline-flex; }
|
||||
.cal-item-bell.off { display: inline-flex; opacity: .55; color: var(--text-3); }
|
||||
.cal-item:hover .cal-item-bell.off { opacity: 1; color: inherit; }
|
||||
.cal-item-bell.off { display: inline-flex; opacity: .55; color: var(--icon-inactive-color); }
|
||||
.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 { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
||||
@@ -639,11 +680,12 @@ a { color: var(--primary); text-decoration: none; }
|
||||
flex: 1; border-right: 1px solid var(--border);
|
||||
cursor: pointer; transition: background var(--transition);
|
||||
padding: 4px 4px 0; min-width: 0;
|
||||
background: var(--day-bg);
|
||||
}
|
||||
.month-col:last-child { border-right: none; }
|
||||
.month-col:hover { background: var(--bg-hover); }
|
||||
.month-col.today { background: color-mix(in srgb, var(--today-color) 10%, transparent); }
|
||||
.month-col.month-selected { background: var(--primary-dim); }
|
||||
.month-col:hover { background: var(--day-hover-color); }
|
||||
.month-col.today { background: color-mix(in srgb, var(--today-bg-base) 10%, transparent); }
|
||||
.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.today { background: var(--today-color); color: #fff; border: none; }
|
||||
.month-col.other-month .cell-day { color: var(--text-3); }
|
||||
@@ -764,7 +806,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
padding: 4px 12px; cursor: pointer;
|
||||
border-radius: 6px; margin: 0 4px;
|
||||
}
|
||||
.mop-row:hover { background: var(--bg-hover); }
|
||||
.mop-row:hover { background: var(--hover-highlight); }
|
||||
.mop-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
@@ -825,7 +867,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
border-left: 1px solid var(--border); cursor: pointer;
|
||||
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 {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: .5px; color: var(--text-2);
|
||||
@@ -977,7 +1019,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
border-radius: 4px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.qtr-cell:hover { background: var(--bg-hover); }
|
||||
.qtr-cell:hover { background: var(--day-hover-color); }
|
||||
.qtr-cell.today .qtr-day-num {
|
||||
background: var(--today-color, var(--primary));
|
||||
color: #fff;
|
||||
@@ -1040,7 +1082,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
cursor: pointer; transition: background var(--transition);
|
||||
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-ev-color { width: 10px; height: 10px; border-radius: 50%; margin-top: 4px; flex-shrink: 0; }
|
||||
.agenda-ev-info { flex: 1; }
|
||||
@@ -1092,7 +1134,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
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); }
|
||||
|
||||
/* ── Day Context Menu ──────────────────────────────────── */
|
||||
@@ -1105,7 +1147,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
.ctx-item {
|
||||
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 ──────────────────────────────────────────
|
||||
Layout: Color-Dot + Title links, kleine Icon-Toolbar rechts oben.
|
||||
@@ -1229,7 +1271,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
border-radius: 10px;
|
||||
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-edit-toggle {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
@@ -1244,6 +1286,10 @@ a { color: var(--primary); text-decoration: none; }
|
||||
/* ── Settings Page ──────────────────────────────────────── */
|
||||
#modal-settings.modal-overlay {
|
||||
align-items: stretch; justify-content: stretch; padding: 0; background: var(--bg-app);
|
||||
/* Full-screen opaque "page". Sit BELOW real modals (z-index 500) so dialogs
|
||||
opened from inside settings — share, add-account, color picker — appear on
|
||||
top instead of behind this page (that's why the Share button "did nothing"). */
|
||||
z-index: 400;
|
||||
}
|
||||
.settings-page-card {
|
||||
width: 100%; height: 100%;
|
||||
@@ -1254,6 +1300,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-topbar);
|
||||
}
|
||||
.settings-page-header h3 { font-size: 16px; font-weight: 600; color: var(--text-1); margin: 0; }
|
||||
.settings-page-body {
|
||||
@@ -1264,6 +1311,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 12px 8px;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
background: var(--bg-sidebar);
|
||||
}
|
||||
.settings-nav-btn {
|
||||
display: block; width: 100%; text-align: left;
|
||||
@@ -1272,13 +1320,18 @@ a { color: var(--primary); text-decoration: none; }
|
||||
background: none; border: none; cursor: pointer;
|
||||
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-panels {
|
||||
flex: 1; overflow-y: auto; padding: 24px 28px;
|
||||
}
|
||||
.settings-panel { display: none; }
|
||||
.settings-panel.active { display: block; }
|
||||
/* Constrain content to a comfortable reading column so fields don't stretch
|
||||
edge-to-edge on wide screens. */
|
||||
.settings-panel.active { display: block; max-width: 680px; }
|
||||
/* …except the Kalender panel, whose wide multi-column table needs the full
|
||||
width (otherwise it's squeezed into a horizontal scroll). */
|
||||
#settings-panel-accounts { max-width: none; }
|
||||
|
||||
/* Panel typography */
|
||||
.panel-title {
|
||||
@@ -1305,7 +1358,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.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 span { font-size: 18px; font-weight: 700; line-height: 1; }
|
||||
.contrast-btn.active span { color: #fff !important; }
|
||||
@@ -1321,6 +1374,131 @@ a { color: var(--primary); text-decoration: none; }
|
||||
}
|
||||
.contrast-btn.active .hour-preview { color: #fff; }
|
||||
|
||||
/* ── Settings sync table (Sync-Icon | Name | Wert) ─────────── */
|
||||
.sync-global-row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
padding: 12px 14px; margin-bottom: 12px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
}
|
||||
.sync-global-text { display: flex; flex-direction: column; gap: 2px; }
|
||||
.sync-global-title { font-size: 14px; font-weight: 600; color: var(--text-1); }
|
||||
.sync-global-row .panel-desc { margin: 0; }
|
||||
|
||||
.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); }
|
||||
|
||||
/* Custom instance logo (replaces the glyph+text). Hard-capped so it can only
|
||||
scale DOWN and never pushes the topbar/auth layout. */
|
||||
.topbar-logo-img { max-height: 40px; max-width: 150px; width: auto; height: auto; object-fit: contain; display: block; }
|
||||
.auth-logo-img { max-height: 56px; max-width: 220px; width: auto; height: auto; object-fit: contain; display: block; }
|
||||
|
||||
/* Admin panel: default-theme editor + branding */
|
||||
.admin-theme-grid { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
|
||||
.admin-theme-row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; }
|
||||
.admin-theme-name { font-size: 13px; color: var(--text-2); }
|
||||
.admin-theme-actions { display: flex; gap: 8px; margin-top: 14px; }
|
||||
.admin-brand-row { display: flex; align-items: center; gap: 12px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.admin-brand-label { width: 70px; font-size: 13px; color: var(--text-2); flex-shrink: 0; }
|
||||
.admin-brand-preview {
|
||||
width: 48px; height: 48px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg-hover); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm); overflow: hidden;
|
||||
}
|
||||
/* Logo preview mirrors its real topbar footprint (max 150×40). */
|
||||
.admin-brand-preview-logo { width: 150px; height: 40px; }
|
||||
.admin-brand-preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.admin-brand-controls { display: flex; flex-direction: column; gap: 4px; }
|
||||
.admin-brand-btns { display: flex; gap: 8px; }
|
||||
.admin-brand-dims { font-size: 11px; color: var(--text-3); }
|
||||
.settings-table-section {
|
||||
font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
|
||||
color: var(--text-3); margin: 18px 0 6px; padding: 0 2px;
|
||||
}
|
||||
.settings-table-section:first-child { margin-top: 0; }
|
||||
.settings-row {
|
||||
display: grid; grid-template-columns: 40px 1fr minmax(140px, auto);
|
||||
align-items: center; gap: 12px;
|
||||
padding: 10px 4px; border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.settings-row:last-child { border-bottom: none; }
|
||||
.settings-row-name { font-size: 14px; color: var(--text-1); }
|
||||
.settings-row-value { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
.settings-row-value select {
|
||||
background: var(--bg-app);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
color: var(--text-1);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
color-scheme: dark;
|
||||
min-width: 130px; max-width: 100%;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.settings-row-value select:hover:not(:focus) { border-color: var(--text-3); }
|
||||
.settings-row-value select:focus { border-color: var(--primary); }
|
||||
|
||||
/* Sync toggle switch — used per row and (larger) globally */
|
||||
.sync-toggle {
|
||||
position: relative; flex: 0 0 auto;
|
||||
width: 34px; height: 20px; padding: 0;
|
||||
border: none; border-radius: 999px; cursor: pointer;
|
||||
background: var(--bg-active); transition: background var(--transition);
|
||||
}
|
||||
.sync-toggle::after {
|
||||
content: ''; position: absolute; top: 2px; left: 2px;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #fff; transition: transform var(--transition);
|
||||
}
|
||||
.sync-toggle.on { background: var(--primary); }
|
||||
.sync-toggle.on::after { transform: translateX(14px); }
|
||||
.sync-toggle-lg { width: 44px; height: 26px; }
|
||||
.sync-toggle-lg::after { width: 22px; height: 22px; }
|
||||
.sync-toggle-lg.on::after { transform: translateX(18px); }
|
||||
.settings-row .sync-toggle { justify-self: center; }
|
||||
|
||||
/* Per-row sync toggle icon: ON = primary + pill, OFF = muted + slashed icon */
|
||||
.sync-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 32px; height: 28px; padding: 0;
|
||||
border: none; border-radius: 8px; cursor: pointer;
|
||||
background: transparent; color: var(--text-3);
|
||||
transition: color var(--transition), background var(--transition);
|
||||
justify-self: center;
|
||||
}
|
||||
.sync-icon:hover { background: var(--hover-highlight); color: var(--text-2); }
|
||||
.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 { display: none; }
|
||||
.sync-icon .si-off { display: inline-flex; }
|
||||
.sync-icon.on .si-off { display: none; }
|
||||
.sync-icon.on .si-on { display: inline-flex; }
|
||||
|
||||
/* Per-row colour control: swatch + hex + reset, right-aligned */
|
||||
.settings-color-ctl { display: flex; align-items: center; gap: 12px; }
|
||||
.settings-color-ctl .ev-color-hex { width: 92px; }
|
||||
.settings-color-ctl .ev-color-preview { margin-left: 2px; }
|
||||
.settings-color-ctl .ev-color-reset {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; padding: 0; color: var(--text-3);
|
||||
}
|
||||
|
||||
/* Inline share-icon picker inside a value cell — one even row */
|
||||
.settings-icon-ctl { display: flex; flex-wrap: nowrap; gap: 6px; justify-content: flex-end; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-row { grid-template-columns: 34px 1fr; grid-auto-rows: auto; }
|
||||
.settings-row-value { grid-column: 1 / -1; justify-content: flex-start; padding-left: 46px; }
|
||||
}
|
||||
|
||||
/* ── Settings (legacy) ──────────────────────────────────── */
|
||||
.settings-section { margin-bottom: 28px; }
|
||||
.settings-section h4 { font-size: 14px; font-weight: 600; color: var(--text-1); margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
|
||||
@@ -1447,7 +1625,18 @@ a { color: var(--primary); text-decoration: none; }
|
||||
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; }
|
||||
/* Fixed layout + narrow drags must clip, not overlap the next column (Excel-like). */
|
||||
.cal-manage-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Full-width detail/section rows (account headers, CalDAV boxes, empty state) wrap normally. */
|
||||
.cal-manage-table td[colspan] { overflow: visible; white-space: normal; }
|
||||
.cal-manage-table tbody tr:last-child td { border-bottom: none; }
|
||||
/* Drag-resizable columns: a grab handle on each header's right edge. */
|
||||
.cal-manage-table th { position: relative; }
|
||||
.cal-manage-table th .col-resizer {
|
||||
position: absolute; top: 0; right: 0; width: 8px; height: 100%;
|
||||
cursor: col-resize; user-select: none; touch-action: none;
|
||||
}
|
||||
.cal-manage-table th .col-resizer:hover { background: var(--border); }
|
||||
.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; }
|
||||
@@ -1472,8 +1661,14 @@ a { color: var(--primary); text-decoration: none; }
|
||||
background: var(--surface-2); color: var(--text-1);
|
||||
}
|
||||
.ct-dav-hint { font-size: 11px; color: var(--text-3); margin-top: 6px; max-width: 640px; }
|
||||
.app-pw-create { display: flex; gap: 8px; align-items: center; }
|
||||
/* Row (not the column inherited from .form-group) so the "Erstellen" button
|
||||
sits inline to the right of the name field instead of centred below it. */
|
||||
.app-pw-create { display: flex; flex-direction: row; gap: 8px; align-items: center; }
|
||||
.app-pw-create input { flex: 1; }
|
||||
|
||||
/* Consistent placement for a section's action button: right-aligned, matching
|
||||
the sticky header save and the app-password create row. */
|
||||
.settings-actions { display: flex; justify-content: flex-end; margin-top: 10px; }
|
||||
.app-pw-new { margin: 8px 0; }
|
||||
.app-pw-item { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-top: 1px solid var(--border); }
|
||||
.app-pw-name { font-weight: 500; }
|
||||
@@ -1571,7 +1766,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
padding: 2px 6px; border-radius: var(--radius-sm);
|
||||
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 */
|
||||
.dtp-grid {
|
||||
display: grid; grid-template-columns: repeat(7, 1fr);
|
||||
@@ -1588,9 +1783,9 @@ a { color: var(--primary); text-decoration: none; }
|
||||
font-size: 13px; font-weight: 500; color: var(--text-1);
|
||||
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: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.selected {
|
||||
background: var(--primary) !important;
|
||||
@@ -2009,14 +2204,50 @@ a { color: var(--primary); text-decoration: none; }
|
||||
}
|
||||
.share-user-item:last-child { border-bottom: none; }
|
||||
.share-user-item:hover { background: var(--bg-surface); }
|
||||
.share-user-item input[type=checkbox] { flex-shrink: 0; }
|
||||
.share-user-item input[type=checkbox] {
|
||||
flex-shrink: 0; width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer;
|
||||
}
|
||||
/* Add-user row: match the app's dark inputs instead of raw white browser
|
||||
controls, with a subtle focus ring for a modern feel. */
|
||||
#share-user-search,
|
||||
#share-permission {
|
||||
background: var(--bg-app);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text-1);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
#share-user-search::placeholder { color: var(--text-3); }
|
||||
#share-permission { cursor: pointer; }
|
||||
#share-user-search:focus,
|
||||
#share-permission:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.15);
|
||||
}
|
||||
/* "Shared with me" section header row inside the calendar-management table */
|
||||
.ct-section-row td {
|
||||
padding-top: 14px; font-size: 12px; font-weight: 600;
|
||||
color: var(--text-3); text-transform: uppercase; letter-spacing: .04em;
|
||||
}
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
|
||||
.checkbox-row input[type=checkbox] { flex-shrink: 0; }
|
||||
/* Higher specificity than the global ".form-group label" (uppercase / spaced /
|
||||
12px) so a checkbox label reads as normal inline text next to its box, not a
|
||||
stretched uppercase column. */
|
||||
.form-group label.checkbox-row,
|
||||
.checkbox-row {
|
||||
display: flex; align-items: center; justify-content: flex-start; gap: 8px;
|
||||
cursor: pointer; text-transform: none; letter-spacing: normal;
|
||||
font-size: 14px; font-weight: 400; color: var(--text-1);
|
||||
}
|
||||
/* Undo the global ".form-group input { width:100%; padding; border }" so the
|
||||
checkbox stays a small native box instead of a full-width field that shoves
|
||||
the label text into a narrow, wrapping column. */
|
||||
.checkbox-row input[type=checkbox] {
|
||||
flex: none; width: 16px; height: 16px; margin: 0; padding: 0;
|
||||
border: 0; border-radius: 0; background: none; accent-color: var(--primary);
|
||||
}
|
||||
/* .popup-creator styling moved into the .popup-row / #popup-creator rules above. */
|
||||
|
||||
/* ── Groups ─────────────────────────────────────────────────── */
|
||||
@@ -2149,7 +2380,7 @@ a { color: var(--primary); text-decoration: none; }
|
||||
.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; }
|
||||
/* 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-opt {
|
||||
width: 38px; height: 38px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4285f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#2ea05a" 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"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 332 B After Width: | Height: | Size: 332 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 27 KiB |
BIN
frontend/icons/icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
frontend/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#4285f4"/>
|
||||
<rect width="512" height="512" fill="#16713d"/>
|
||||
<g fill="none" stroke="#ffffff" stroke-width="23" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="102" y="154" width="307" height="266" rx="26"/>
|
||||
<line x1="102" y1="234" x2="409" y2="234"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 432 B After Width: | Height: | Size: 432 B |
@@ -7,7 +7,8 @@
|
||||
<title>Calendarr</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#4285f4" />
|
||||
<meta name="theme-color" content="#16713d" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Calendarr" />
|
||||
@@ -158,10 +159,19 @@
|
||||
<!-- SIDEBAR -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-inner">
|
||||
<button class="btn btn-fab" id="btn-create-event">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<span data-i18n="btn_create">Erstellen</span>
|
||||
</button>
|
||||
<div class="create-split add-cal-dropdown-wrap">
|
||||
<button class="btn btn-fab create-main" id="btn-create-event">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<span data-i18n="btn_create">Erstellen</span>
|
||||
</button>
|
||||
<button class="btn btn-fab create-caret" id="btn-create-menu" data-i18n-title="birthday_new" title="Neuer Geburtstag" aria-haspopup="menu" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M7 10l5 5 5-5z"/></svg>
|
||||
</button>
|
||||
<div class="add-cal-dropdown create-menu hidden" id="create-menu" role="menu">
|
||||
<button data-action="event" data-i18n="create_event_option">Neuer Termin</button>
|
||||
<button data-action="birthday" data-i18n="birthday_new">Neuer Geburtstag</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mini Calendar -->
|
||||
<div class="mini-cal" id="mini-cal">
|
||||
@@ -197,6 +207,7 @@
|
||||
</button>
|
||||
<div class="add-cal-dropdown hidden" id="add-cal-dropdown">
|
||||
<button data-action="local">Lokaler Kalender</button>
|
||||
<button data-action="birthday" data-i18n="birthday_calendar_type">Geburtstagskalender</button>
|
||||
<button data-action="caldav">CalDAV-Konto</button>
|
||||
<button data-action="ical">iCal-URL abonnieren</button>
|
||||
<button data-action="google">Google Kalender</button>
|
||||
@@ -477,6 +488,24 @@
|
||||
</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 -->
|
||||
<div id="popup-event" class="event-popup hidden">
|
||||
<div class="popup-header">
|
||||
@@ -589,6 +618,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Birthday modal -->
|
||||
<div id="modal-birthday" class="modal-overlay hidden">
|
||||
<div class="modal-card" style="max-width:400px">
|
||||
<div class="modal-header">
|
||||
<h3 data-i18n="birthday_modal_title">Neuen Geburtstag hinzufügen</h3>
|
||||
<button class="icon-btn modal-close" data-modal="modal-birthday">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="birthday-no-cal" class="hidden">
|
||||
<div class="form-hint" data-i18n="birthday_activate_hint">Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.</div>
|
||||
<button class="btn btn-primary btn-sm" id="birthday-activate" data-i18n="birthday_activate">Geburtstagskalender aktivieren</button>
|
||||
</div>
|
||||
<div id="birthday-form">
|
||||
<div class="form-group">
|
||||
<label data-i18n="birthday_person">Name</label>
|
||||
<input type="text" id="birthday-name" data-i18n-ph="birthday_person_ph" placeholder="Name der Person" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="birthday_date">Geburtstag</label>
|
||||
<div class="birthday-date-row">
|
||||
<select id="birthday-day"></select>
|
||||
<select id="birthday-month"></select>
|
||||
<input type="number" id="birthday-year" min="1900" max="2100" data-i18n-ph="birthday_year_ph" placeholder="Jahr" />
|
||||
</div>
|
||||
<label class="checkbox-row" style="margin-top:10px;display:flex;align-items:center;gap:8px;text-transform:none;letter-spacing:normal;font-weight:400;cursor:pointer">
|
||||
<input type="checkbox" id="birthday-year-unknown" style="width:16px;height:16px;flex:none;margin:0" />
|
||||
<span data-i18n="birthday_year_unknown">Jahr unbekannt</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" data-modal="modal-birthday" data-i18n="cancel">Abbrechen</button>
|
||||
<button class="btn btn-primary" id="birthday-save" data-i18n="save">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- iCal Subscription Modal -->
|
||||
<div id="modal-ical-sub" class="modal-overlay hidden">
|
||||
<div class="modal-card" style="max-width:480px">
|
||||
@@ -693,7 +760,7 @@
|
||||
<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="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_admin">Admin</button>
|
||||
</nav>
|
||||
|
||||
<div class="settings-panels">
|
||||
@@ -714,7 +781,6 @@
|
||||
<label>E-Mail</label>
|
||||
<input type="email" id="cfg-email" placeholder="Keine E-Mail hinterlegt" />
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="cfg-profile-save" data-i18n="save">Speichern</button>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="app_pw_title">App-Passwörter (CalDAV)</h4>
|
||||
<p class="panel-desc" data-i18n="app_pw_desc">Eigene Passwörter für CalDAV-Clients. Bei aktivem 2FA nötig, da Apps keinen 2FA-Code eingeben können. Jederzeit widerrufbar.</p>
|
||||
@@ -750,16 +816,6 @@
|
||||
<p class="panel-desc" data-i18n="settings_directory_hidden_desc">Andere Nutzer können dich dann nicht auswählen, um Kalender zu teilen oder dich zu Gruppen hinzuzufügen. In der Admin-Benutzerverwaltung bleibst du sichtbar.</p>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_default_duration">Standard-Termindauer</h4>
|
||||
<div class="contrast-selector" id="cfg-event-duration">
|
||||
<button class="contrast-btn" data-val="15">15 min</button>
|
||||
<button class="contrast-btn" data-val="30">30 min</button>
|
||||
<button class="contrast-btn" data-val="45">45 min</button>
|
||||
<button class="contrast-btn" data-val="60">1 h</button>
|
||||
<button class="contrast-btn" data-val="90">1,5 h</button>
|
||||
<button class="contrast-btn" data-val="120">2 h</button>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_calendars">Geteilter Kalender</h4>
|
||||
<p class="panel-desc" data-i18n="settings_group_visible_desc">Wähle, welcher deiner Kalender für deine Gruppenmitglieder sichtbar ist</p>
|
||||
<div class="form-group">
|
||||
@@ -768,115 +824,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Darstellung: Kalenderansicht, Sprache, Stundenhöhe, Farben -->
|
||||
<!-- Darstellung: einheitliche Sync-Tabelle (Sync-Icon | Name | Wert) -->
|
||||
<div class="settings-panel" id="settings-panel-general">
|
||||
|
||||
<h4 class="panel-title" data-i18n="settings_calendar_view">Kalenderansicht</h4>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_default_view">Standardansicht</label>
|
||||
<select id="cfg-default-view">
|
||||
<option value="month" data-i18n="view_month">Monat</option>
|
||||
<option value="week" data-i18n="view_week">Woche</option>
|
||||
<option value="day" data-i18n="view_day">Tag</option>
|
||||
<option value="quarter" data-i18n="view_quarter">Quartal</option>
|
||||
<option value="agenda" data-i18n="view_agenda">Termine</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_week_start">Erster Wochentag</label>
|
||||
<select id="cfg-week-start">
|
||||
<option value="monday" data-i18n="week_start_monday">Montag</option>
|
||||
<option value="sunday" data-i18n="week_start_sunday">Sonntag</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" id="cfg-dim-past" />
|
||||
<span data-i18n="settings_dim_past">Vergangene Termine ausgrauen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="settings_language">Sprache</h4>
|
||||
<div class="form-group">
|
||||
<select id="cfg-language">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</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>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_primary_color">Primärfarbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-primary-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-primary-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
<div class="sync-global-row">
|
||||
<div class="sync-global-text">
|
||||
<span class="sync-global-title" data-i18n="settings_sync_all">Alle synchronisieren</span>
|
||||
<span class="panel-desc" data-i18n="settings_sync_all_desc">Diese Einstellungen zwischen deinen Geräten teilen</span>
|
||||
</div>
|
||||
<button type="button" class="sync-toggle sync-toggle-lg" id="cfg-sync-all" role="switch" aria-checked="false"></button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_accent_color">Akzentfarbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-accent-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-accent-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_today_color">Heutige-Tag-Farbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-today-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-today-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_month_divider_color">Monatswechsel-Linie</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-month-divider-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-month-divider-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_month_label_color">Monatskürzel</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-month-label-hex" class="ev-color-hex" maxlength="7" spellcheck="false" />
|
||||
<div class="ev-color-preview" id="cfg-month-label-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_text_color">Schriftfarbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-text-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
|
||||
<div class="ev-color-preview" id="cfg-text-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="cfg-text-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_line_color">Linienfarbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-line-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
|
||||
<div class="ev-color-preview" id="cfg-line-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="cfg-line-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings_bg_color">Hintergrundfarbe</label>
|
||||
<div class="ev-color-row">
|
||||
<input type="text" id="cfg-bg-color-hex" class="ev-color-hex" maxlength="7" spellcheck="false" placeholder="auto" />
|
||||
<div class="ev-color-preview" id="cfg-bg-color-preview" data-i18n-title="color_pick" title="Farbe wählen"></div>
|
||||
<button type="button" class="btn btn-ghost btn-sm" id="cfg-bg-color-reset" data-i18n="reset" title="Zurücksetzen">Reset</button>
|
||||
</div>
|
||||
<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.json,.json,.theme,application/json" hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -885,15 +847,19 @@
|
||||
<h4 class="panel-title" data-i18n="settings_nav_calendars">Kalender</h4>
|
||||
<div class="accounts-add-row">
|
||||
<button class="btn btn-secondary btn-sm" id="settings-btn-add-local">+ Lokal</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-btn-add-birthday" data-i18n="birthday_add_btn">+ Geburtstage</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-btn-add-caldav">+ CalDAV</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ical">+ iCal</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-btn-add-ha">+ Home Assistant</button>
|
||||
<button class="btn btn-secondary btn-sm" id="settings-btn-add-google">+ Google</button>
|
||||
</div>
|
||||
<div id="cal-settings-table" style="margin-top:16px;overflow-x:auto"></div>
|
||||
|
||||
<h4 class="panel-title" style="margin-top:24px" data-i18n="birthday_settings_title">Geburtstage</h4>
|
||||
<div id="birthday-settings"></div>
|
||||
</div>
|
||||
|
||||
<!-- Benutzerverwaltung -->
|
||||
<!-- Admin: Benutzerverwaltung + Standard-Theme + Branding -->
|
||||
<div class="settings-panel" id="settings-panel-users">
|
||||
<h4 class="panel-title"><span data-i18n="settings_nav_users">Benutzerverwaltung</span> <span class="badge-admin">Admin</span></h4>
|
||||
<div id="users-list"></div>
|
||||
@@ -912,6 +878,45 @@
|
||||
</label>
|
||||
<button class="btn btn-primary" id="new-user-save">Erstellen</button>
|
||||
</div>
|
||||
|
||||
<!-- Instanz-Standardtheme -->
|
||||
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_default_theme">Standard-Theme</h4>
|
||||
<p class="panel-desc" data-i18n="admin_default_theme_desc">Gilt für alle Nutzer, die keine eigene Farbe gesetzt haben. „Zurück" bei einer Farbe springt auf diesen Standard.</p>
|
||||
<div id="admin-theme-grid" class="admin-theme-grid"></div>
|
||||
<div class="admin-theme-actions">
|
||||
<button class="btn btn-primary btn-sm" id="admin-theme-save" data-i18n="admin_theme_save">Standard speichern</button>
|
||||
<button class="btn btn-secondary btn-sm" id="admin-theme-import" data-i18n="admin_theme_import">Theme importieren</button>
|
||||
<button class="btn btn-ghost btn-sm" id="admin-theme-discard" data-i18n="admin_theme_discard">Verwerfen</button>
|
||||
<input type="file" id="admin-theme-file" accept=".theme.json,.json,.theme,application/json" hidden />
|
||||
</div>
|
||||
|
||||
<!-- Branding: Logo & Favicon -->
|
||||
<h4 class="panel-title" style="margin-top:28px" data-i18n="admin_branding">Branding</h4>
|
||||
<p class="panel-desc" data-i18n="admin_branding_desc">Eigenes Logo (oben links) und Favicon (Tab-Symbol) für die ganze Instanz. PNG/JPEG/WebP, max. 5 MB.</p>
|
||||
<div class="admin-brand-row">
|
||||
<span class="admin-brand-label" data-i18n="admin_logo">Logo</span>
|
||||
<div class="admin-brand-preview admin-brand-preview-logo" id="admin-logo-preview"></div>
|
||||
<div class="admin-brand-controls">
|
||||
<div class="admin-brand-btns">
|
||||
<button class="btn btn-secondary btn-sm" id="admin-logo-upload" data-i18n="admin_upload">Hochladen</button>
|
||||
<button class="btn btn-ghost btn-sm" id="admin-logo-remove" data-i18n="admin_remove">Entfernen</button>
|
||||
</div>
|
||||
<span class="admin-brand-dims" data-i18n="admin_logo_dims">PNG/JPEG/WebP · Anzeige max. 150 × 40 px</span>
|
||||
</div>
|
||||
<input type="file" id="admin-logo-file" accept="image/png,image/jpeg,image/webp" hidden />
|
||||
</div>
|
||||
<div class="admin-brand-row">
|
||||
<span class="admin-brand-label" data-i18n="admin_favicon">Favicon</span>
|
||||
<div class="admin-brand-preview" id="admin-favicon-preview"></div>
|
||||
<div class="admin-brand-controls">
|
||||
<div class="admin-brand-btns">
|
||||
<button class="btn btn-secondary btn-sm" id="admin-favicon-upload" data-i18n="admin_upload">Hochladen</button>
|
||||
<button class="btn btn-ghost btn-sm" id="admin-favicon-remove" data-i18n="admin_remove">Entfernen</button>
|
||||
</div>
|
||||
<span class="admin-brand-dims" data-i18n="admin_favicon_dims">PNG/JPEG/WebP · 128 × 128 px (quadratisch)</span>
|
||||
</div>
|
||||
<input type="file" id="admin-favicon-file" accept="image/png,image/jpeg,image/webp" hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- settings-panels -->
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { api } from './api.js';
|
||||
import { initCalendar, showToast, openProfileModal } from './calendar.js';
|
||||
import { t } from './i18n.js';
|
||||
import { loadInstance } from './instance.js';
|
||||
|
||||
// ── Bootstrap ─────────────────────────────────────────────
|
||||
async function boot() {
|
||||
// Apply instance branding (logo/favicon/default theme) ASAP so the login and
|
||||
// setup screens are already branded. Public endpoint — no token needed.
|
||||
loadInstance();
|
||||
// Check if setup is required
|
||||
let setupRequired = false;
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or ""
|
||||
* mode : 'datetime' | 'date'
|
||||
*/
|
||||
import { t } from './i18n.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
|
||||
const ITEM_H = 40; // px per scroll item
|
||||
const VISIBLE = 3; // visible items in time scroller
|
||||
@@ -250,16 +250,16 @@ export function openDatePicker(anchor, value, mode = 'datetime') {
|
||||
/**
|
||||
* Format an ISO value for display in the UI
|
||||
* mode: 'datetime' | 'date'
|
||||
* lang: 'de' | 'en'
|
||||
* Formatting follows the active UI language via the i18n locale registry.
|
||||
*/
|
||||
export function formatDtDisplay(isoStr, mode, lang = 'de') {
|
||||
export function formatDtDisplay(isoStr, mode) {
|
||||
if (!isoStr) return '—';
|
||||
try {
|
||||
const d = mode === 'datetime'
|
||||
? new Date(isoStr.replace(' ', 'T'))
|
||||
: new Date(isoStr + 'T00:00:00');
|
||||
if (isNaN(d)) return isoStr;
|
||||
const locale = lang === 'en' ? 'en-GB' : 'de-CH';
|
||||
const locale = getLocale();
|
||||
if (mode === 'datetime') {
|
||||
return d.toLocaleString(locale, {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
|
||||
3378
frontend/js/i18n.js
56
frontend/js/instance.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Instance-wide branding + default theme (public GET /api/instance/). Loaded as
|
||||
// early as possible so the login screen is already branded. See
|
||||
// backend/routers/admin_router.py.
|
||||
|
||||
import { setInstanceDefaults } from './settings-sync.js';
|
||||
import { setCustomFavicon, applyFavicon } from './utils.js';
|
||||
|
||||
export let instanceConfig = {};
|
||||
|
||||
// Cache the bundled default logo markup so "remove logo" can restore it.
|
||||
const originalLogoHtml = {};
|
||||
|
||||
function setBrandLogo(url) {
|
||||
document.querySelectorAll('.topbar-logo, .auth-logo').forEach(el => {
|
||||
const key = el.classList.contains('topbar-logo') ? 'topbar' : 'auth';
|
||||
if (!(key in originalLogoHtml)) originalLogoHtml[key] = el.innerHTML;
|
||||
if (url) {
|
||||
const cls = key === 'topbar' ? 'topbar-logo-img' : 'auth-logo-img';
|
||||
el.innerHTML = `<img class="${cls}" src="${url}" alt="Logo">`;
|
||||
} else {
|
||||
el.innerHTML = originalLogoHtml[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applyInstanceBranding(cfg) {
|
||||
cfg = cfg || {};
|
||||
setInstanceDefaults(cfg.default_theme || {});
|
||||
setCustomFavicon(cfg.has_favicon ? cfg.favicon_url : null);
|
||||
setBrandLogo(cfg.has_logo ? cfg.logo_url : null);
|
||||
applyFavicon(); // no arg → uses instance/built-in primary as fallback
|
||||
}
|
||||
|
||||
// Memoised so boot() and initCalendar() share a single fetch; callers can await
|
||||
// it to guarantee instance defaults are set before the first applyTheme().
|
||||
let loadPromise = null;
|
||||
export function loadInstance() {
|
||||
if (loadPromise) return loadPromise;
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/instance/', { headers: { Accept: 'application/json' } });
|
||||
instanceConfig = res.ok ? await res.json() : {};
|
||||
} catch (_) {
|
||||
instanceConfig = {};
|
||||
}
|
||||
applyInstanceBranding(instanceConfig);
|
||||
return instanceConfig;
|
||||
})();
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
// Force a re-fetch (after an admin changes branding/theme).
|
||||
export function reloadInstance() {
|
||||
loadPromise = null;
|
||||
return loadInstance();
|
||||
}
|
||||
137
frontend/js/settings-sync.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// Per-setting cross-device sync for the web client.
|
||||
//
|
||||
// The server is the sole authority for WHICH settings sync (it returns a fully
|
||||
// resolved `sync_flags` map). This module owns the browser-local copy of every
|
||||
// syncable value so that "not synced" works per browser, plus the declarative
|
||||
// table definition the settings UI renders from. See backend/SETTINGS_SYNC.md.
|
||||
|
||||
import { LANGUAGES } from './i18n.js';
|
||||
|
||||
// Canonical default colours — the single source for the web. Reset writes these.
|
||||
export const DEFAULT_COLORS = {
|
||||
primary_color: '#58B900',
|
||||
accent_color: '#45A148',
|
||||
today_color: '#6FB669',
|
||||
text_color: '#FFFFFF',
|
||||
bg_color: '#000000',
|
||||
line_color: '#3B3B3D',
|
||||
surface_color: '#2B2B2B',
|
||||
month_divider_color: '#95D25E',
|
||||
month_label_color: '#95D25E',
|
||||
// Fine-grained element colours (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: '#90AA91',
|
||||
icon_active_color: '#E8E8F0',
|
||||
day_hover_color: '#2B382A',
|
||||
day_selected_color: '#88EF9A',
|
||||
day_bg_color: '#000000',
|
||||
today_bg_color: '#477650',
|
||||
};
|
||||
|
||||
// The syncable settings the web client exposes, grouped into table sections.
|
||||
// `type`: 'select' | 'toggle' | 'color' | 'icon'.
|
||||
// Option labels use i18n keys via `tk`, or a literal `label`.
|
||||
export const SETTING_GROUPS = [
|
||||
{
|
||||
titleKey: 'settings_calendar_view',
|
||||
rows: [
|
||||
{ key: 'default_view', labelKey: 'settings_default_view', type: 'select', opts: [
|
||||
{ v: 'month', tk: 'view_month' }, { v: 'week', tk: 'view_week' },
|
||||
{ v: 'day', tk: 'view_day' }, { v: 'quarter', tk: 'view_quarter' },
|
||||
{ v: 'agenda', tk: 'view_agenda' },
|
||||
] },
|
||||
{ key: 'week_start_day', labelKey: 'settings_week_start', type: 'select', opts: [
|
||||
{ v: 'monday', tk: 'week_start_monday' }, { v: 'sunday', tk: 'week_start_sunday' },
|
||||
] },
|
||||
{ key: 'dim_past_events', labelKey: 'settings_dim_past', type: 'toggle' },
|
||||
{ key: 'month_view_paged', labelKey: 'settings_month_mode', type: 'select', opts: [
|
||||
{ v: false, tk: 'settings_month_mode_scroll' }, { v: true, tk: 'settings_month_mode_paged' },
|
||||
] },
|
||||
{ key: 'hour_height', labelKey: 'settings_hour_height', type: 'select', opts: [
|
||||
{ v: 28, tk: 'hour_compact' }, { v: 44, tk: 'hour_normal' },
|
||||
{ v: 60, tk: 'hour_comfort' }, { v: 80, tk: 'hour_large' },
|
||||
] },
|
||||
{ key: 'default_event_duration_minutes', labelKey: 'settings_default_duration', type: 'select', opts: [
|
||||
{ v: 15, label: '15 min' }, { v: 30, label: '30 min' }, { v: 45, label: '45 min' },
|
||||
{ v: 60, label: '1 h' }, { v: 90, label: '1,5 h' }, { v: 120, label: '2 h' },
|
||||
] },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'settings_language',
|
||||
rows: [
|
||||
// Derived from the i18n registry: adding a language there adds it here.
|
||||
// Each language is listed under its own endonym, so the labels are never
|
||||
// translated — a Finn looks for "Suomi", not "Finnisch".
|
||||
{ key: 'language', labelKey: 'settings_language', type: 'select',
|
||||
opts: LANGUAGES.map(l => ({ v: l.code, label: l.label })) },
|
||||
{ key: 'share_calendar_icon', labelKey: 'settings_share_icon', type: 'icon' },
|
||||
],
|
||||
},
|
||||
{
|
||||
titleKey: 'settings_colors',
|
||||
rows: [
|
||||
{ key: 'primary_color', labelKey: 'settings_primary_color', type: 'color' },
|
||||
{ key: 'accent_color', labelKey: 'settings_accent_color', type: 'color' },
|
||||
{ key: 'today_color', labelKey: 'settings_today_color', type: 'color' },
|
||||
{ key: 'text_color', labelKey: 'settings_text_color', type: 'color' },
|
||||
{ key: 'bg_color', labelKey: 'settings_bg_color', type: 'color' },
|
||||
{ key: 'surface_color', labelKey: 'settings_surface_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_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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Flat list of every syncable key the web manages (table order).
|
||||
export const SYNCABLE_KEYS = SETTING_GROUPS.flatMap(g => g.rows.map(r => r.key));
|
||||
|
||||
// Instance-wide default theme set by an admin (from GET /api/instance/). It is
|
||||
// the base a user inherits and the target that a per-colour "Reset" returns to.
|
||||
// A user's own value always overrides it. See backend/routers/admin_router.py.
|
||||
export let INSTANCE_DEFAULTS = {};
|
||||
export function setInstanceDefaults(obj) { INSTANCE_DEFAULTS = obj || {}; }
|
||||
// The effective default for a colour key: admin instance default → built-in.
|
||||
export function baseColor(key) { return INSTANCE_DEFAULTS[key] || DEFAULT_COLORS[key]; }
|
||||
|
||||
const LOCAL_KEY = 'settingsLocal';
|
||||
|
||||
export function loadLocal() {
|
||||
try { return JSON.parse(localStorage.getItem(LOCAL_KEY) || '{}') || {}; }
|
||||
catch (_) { return {}; }
|
||||
}
|
||||
|
||||
export function saveLocal(obj) {
|
||||
try { localStorage.setItem(LOCAL_KEY, JSON.stringify(obj)); } catch (_) {}
|
||||
}
|
||||
|
||||
// Effective value of a syncable key: synced → server value; otherwise the
|
||||
// browser-local value (falling back to the server value if we have none yet).
|
||||
export function effectiveValue(key, server, flags, local) {
|
||||
if (flags[key]) return server[key];
|
||||
return (key in local && local[key] != null) ? local[key] : server[key];
|
||||
}
|
||||
|
||||
// Build the effective settings object: a copy of the raw server settings with
|
||||
// each syncable key resolved to its effective value. As a side effect, mirror
|
||||
// every effective value into the local copy so that flipping a flag OFF later
|
||||
// retains the currently-visible value.
|
||||
export function mergeEffective(server, flags, local) {
|
||||
const eff = { ...server };
|
||||
for (const key of SYNCABLE_KEYS) {
|
||||
const val = effectiveValue(key, server, flags, local);
|
||||
eff[key] = val;
|
||||
if (val != null) local[key] = val;
|
||||
}
|
||||
saveLocal(local);
|
||||
return eff;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { DEFAULT_COLORS, INSTANCE_DEFAULTS, baseColor } from './settings-sync.js';
|
||||
import { getLocale } from './i18n.js';
|
||||
|
||||
export function isToday(d) {
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear() &&
|
||||
@@ -16,8 +19,28 @@ export function isPast(ev) {
|
||||
return end < new Date();
|
||||
}
|
||||
|
||||
// Title to render for an event: the server-decorated one (birthday age, group
|
||||
// prefix) wins over the raw title, which stays untouched for editing.
|
||||
export function eventTitle(ev) {
|
||||
return ev.display_title || ev.title || '';
|
||||
}
|
||||
|
||||
// Small inline cake icon for birthday events, sized to the surrounding text and
|
||||
// tinted with the current text colour so it matches every bar it's dropped into.
|
||||
export function birthdayIconSvg() {
|
||||
return '<svg viewBox="0 0 24 24" aria-hidden="true" '
|
||||
+ 'style="width:0.85em;height:0.85em;vertical-align:-0.1em;margin-right:2px;flex:0 0 auto">'
|
||||
+ '<path fill="currentColor" d="M12 6c1.11 0 2-.9 2-2 0-.38-.1-.73-.29-1.03L12 0l-1.71 2.97'
|
||||
+ 'c-.19.3-.29.65-.29 1.03 0 1.1.9 2 2 2zm4.6 9.99l-1.07-1.07-1.08 1.07c-1.3 1.3-3.58 1.31-4.89 0'
|
||||
+ 'l-1.07-1.07-1.09 1.07C6.75 16.64 5.88 17 4.96 17c-.73 0-1.4-.23-1.96-.61V21c0 .55.45 1 1 1h16'
|
||||
+ 'c.55 0 1-.45 1-1v-4.61c-.56.38-1.23.61-1.96.61-.92 0-1.79-.36-2.44-1.01zM18 9h-5V7h-2v2H6'
|
||||
+ 'c-1.66 0-3 1.34-3 3v1.54c0 1.08.88 1.96 1.96 1.96.52 0 1.02-.2 1.38-.57l2.14-2.13 2.13 2.13'
|
||||
+ 'c.74.74 2.03.74 2.77 0l2.14-2.13 2.13 2.13c.37.37.86.57 1.38.57 1.08 0 1.96-.88 1.96-1.96V12'
|
||||
+ 'c.01-1.66-1.33-3-2.99-3z"/></svg>';
|
||||
}
|
||||
|
||||
export function formatDate(d, opts = {}) {
|
||||
return d.toLocaleDateString('de', opts);
|
||||
return d.toLocaleDateString(getLocale(), opts);
|
||||
}
|
||||
|
||||
export function dateKey(d) {
|
||||
@@ -76,23 +99,26 @@ const LINE_CONTRAST = {
|
||||
4: { border: '#5a5a78', light: '#484860' },
|
||||
};
|
||||
|
||||
// Defaults wenn kein Custom-Override gesetzt ist.
|
||||
// Bewusst hart "weiss auf schwarz" damit man nie unsichtbar landet.
|
||||
export const DEFAULT_TEXT_COLOR = '#FFFFFF';
|
||||
export const DEFAULT_LINE_COLOR = '#3A3A52';
|
||||
export const DEFAULT_BG_COLOR = '#000000';
|
||||
// Default-Farben: EINZIGE Quelle ist DEFAULT_COLORS in settings-sync.js.
|
||||
// Dort ändern → wirkt für Reset (Tabelle) und diese Theme-Fallbacks gleichzeitig.
|
||||
export const DEFAULT_TEXT_COLOR = DEFAULT_COLORS.text_color;
|
||||
export const DEFAULT_LINE_COLOR = DEFAULT_COLORS.line_color;
|
||||
export const DEFAULT_BG_COLOR = DEFAULT_COLORS.bg_color;
|
||||
|
||||
export function applyTheme(settings) {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--primary', settings.primary_color || '#4285f4');
|
||||
root.style.setProperty('--primary-dim', hexToRgba(settings.primary_color || '#4285f4', 0.15));
|
||||
root.style.setProperty('--accent', settings.accent_color || '#ea4335');
|
||||
root.style.setProperty('--today-color', settings.today_color || '#4285f4');
|
||||
// Fallback chain for a colour that has no per-user value: admin instance
|
||||
// default → built-in default (baseColor()).
|
||||
const primary = settings.primary_color || baseColor('primary_color');
|
||||
root.style.setProperty('--primary', primary);
|
||||
root.style.setProperty('--primary-dim', hexToRgba(primary, 0.15));
|
||||
root.style.setProperty('--accent', settings.accent_color || baseColor('accent_color'));
|
||||
root.style.setProperty('--today-color', settings.today_color || baseColor('today_color'));
|
||||
|
||||
// Effektive Farben bestimmen (Override > Default).
|
||||
let textColor = settings.text_color || DEFAULT_TEXT_COLOR;
|
||||
let lineColor = settings.line_color || DEFAULT_LINE_COLOR;
|
||||
let bgColor = settings.bg_color || DEFAULT_BG_COLOR;
|
||||
// Effektive Farben bestimmen (Override > Admin-Default > eingebauter Default).
|
||||
let textColor = settings.text_color || baseColor('text_color');
|
||||
let lineColor = settings.line_color || baseColor('line_color');
|
||||
let bgColor = settings.bg_color || baseColor('bg_color');
|
||||
|
||||
// Sicherheitsbremse: Wenn Schrift- und Hintergrundfarbe nicht genug
|
||||
// Kontrast haben (passiert wenn man aus Versehen text=bg eingibt),
|
||||
@@ -110,18 +136,73 @@ export function applyTheme(settings) {
|
||||
root.style.setProperty('--border', lineColor);
|
||||
root.style.setProperty('--border-light', shadeHex(lineColor, -0.25));
|
||||
|
||||
// Surface family (sidebar / top bar / cards). Explicit surface_color drives
|
||||
// it; otherwise derive from the app background as before.
|
||||
const surfaceBase = settings.surface_color || shadeHex(bgColor, 0.10);
|
||||
root.style.setProperty('--bg-app', bgColor);
|
||||
root.style.setProperty('--bg-topbar', shadeHex(bgColor, 0.10));
|
||||
root.style.setProperty('--bg-sidebar', shadeHex(bgColor, 0.10));
|
||||
root.style.setProperty('--bg-surface', shadeHex(bgColor, 0.18));
|
||||
root.style.setProperty('--bg-hover', shadeHex(bgColor, 0.26));
|
||||
root.style.setProperty('--bg-active', shadeHex(bgColor, 0.40));
|
||||
root.style.setProperty('--bg-topbar', surfaceBase);
|
||||
root.style.setProperty('--bg-sidebar', surfaceBase);
|
||||
root.style.setProperty('--bg-surface', shadeHex(surfaceBase, 0.10));
|
||||
root.style.setProperty('--bg-hover', shadeHex(surfaceBase, 0.20));
|
||||
root.style.setProperty('--bg-active', shadeHex(surfaceBase, 0.34));
|
||||
|
||||
const hh = settings.hour_height || 44;
|
||||
root.style.setProperty('--hour-h', hh + 'px');
|
||||
|
||||
root.style.setProperty('--month-divider-color', settings.month_divider_color || '#7090c0');
|
||||
root.style.setProperty('--month-label-color', settings.month_label_color || '#7090c0');
|
||||
root.style.setProperty('--month-divider-color', settings.month_divider_color || baseColor('month_divider_color'));
|
||||
root.style.setProperty('--month-label-color', settings.month_label_color || baseColor('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.
|
||||
// User value → admin instance default (if any). When neither is set the
|
||||
// property stays unset so the derived :root default keeps the current look.
|
||||
const setIf = (varName, key) => {
|
||||
const value = settings[key] || INSTANCE_DEFAULTS[key];
|
||||
if (value) root.style.setProperty(varName, value);
|
||||
};
|
||||
setIf('--hover-highlight', 'hover_highlight_color');
|
||||
setIf('--icon-inactive-color', 'icon_inactive_color');
|
||||
setIf('--icon-active-color', 'icon_active_color');
|
||||
setIf('--day-hover-color', 'day_hover_color');
|
||||
setIf('--day-selected-base', 'day_selected_color');
|
||||
setIf('--day-bg', 'day_bg_color');
|
||||
setIf('--today-bg-base', 'today_bg_color');
|
||||
}
|
||||
|
||||
// Instance custom favicon URL (set by the branding loader). When present it
|
||||
// overrides the primary-colour tinting.
|
||||
let customFaviconUrl = null;
|
||||
export function setCustomFavicon(url) { customFaviconUrl = url || null; }
|
||||
|
||||
// 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 || baseColor('primary_color');
|
||||
let link = document.querySelector('link[rel="icon"]');
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (customFaviconUrl) {
|
||||
// Admin-uploaded favicon: use as-is (no primary-colour tinting).
|
||||
link.type = 'image/png';
|
||||
link.href = customFaviconUrl;
|
||||
} else {
|
||||
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>';
|
||||
link.type = 'image/svg+xml';
|
||||
link.href = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||
}
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', color);
|
||||
}
|
||||
|
||||
function luminance(hex) {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Increment APP_VERSION with every code change
|
||||
export const APP_VERSION = 'v74';
|
||||
export const APP_VERSION = 'v90';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isPast } from '../utils.js';
|
||||
import { t, getLang } from '../i18n.js';
|
||||
import { isPast, eventTitle, birthdayIconSvg } from '../utils.js';
|
||||
import { t, getLocale } from '../i18n.js';
|
||||
|
||||
export function renderAgenda(container, currentDate, events, onEventClick) {
|
||||
if (!events.length) {
|
||||
@@ -45,7 +45,7 @@ export function renderAgenda(container, currentDate, events, onEventClick) {
|
||||
return `<div class="agenda-event ${pastCls}" data-id="${ev.id}" data-url="${escAttr(ev.url)}">
|
||||
<div class="agenda-ev-color" style="background:${color}"></div>
|
||||
<div class="agenda-ev-info">
|
||||
<div class="agenda-ev-title">${escHtml(ev.title)}</div>
|
||||
<div class="agenda-ev-title">${ev.is_birthday ? birthdayIconSvg() : ''}${escHtml(eventTitle(ev))}</div>
|
||||
<div class="agenda-ev-meta">${timeStr}${locHtml}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -94,7 +94,7 @@ function isTodayDate(d) {
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString(getLang(), { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js';
|
||||
import { t, getLocale } from '../i18n.js';
|
||||
|
||||
const LANE_H = 20; // px per lane (event height 18px + 2px gap)
|
||||
const DAY_H = 30; // day-number row height
|
||||
@@ -110,14 +110,15 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
|
||||
const pastCls = isPast(ev) ? 'past' : '';
|
||||
const cL = continuesLeft ? 'continues-left' : '';
|
||||
const cR = continuesRight ? 'continues-right' : '';
|
||||
const titleEsc = escHtml(ev.title);
|
||||
const titleEsc = escHtml(eventTitle(ev));
|
||||
const icon = ev.is_birthday ? birthdayIconSvg() : '';
|
||||
const labelHtml = ev.allDay
|
||||
? titleEsc
|
||||
: `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${titleEsc}`;
|
||||
? icon + titleEsc
|
||||
: `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${icon}${titleEsc}`;
|
||||
eventsHtml += `<div class="month-span-event ${pastCls} ${cL} ${cR}"
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}"
|
||||
style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;top:${topPx}px;background:${color}"
|
||||
title="${escAttr(ev.title)}">${labelHtml}</div>`;
|
||||
title="${escAttr(eventTitle(ev))}">${labelHtml}</div>`;
|
||||
});
|
||||
|
||||
// "+N more" per column
|
||||
@@ -250,7 +251,7 @@ function dateKey(d) {
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
@@ -292,7 +293,7 @@ function showOverflowPopup(anchor, date, events, onEventClick) {
|
||||
+ (continuesLeft ? ' continues-left' : '')
|
||||
+ (continuesRight ? ' continues-right' : '');
|
||||
bar.style.background = color;
|
||||
bar.textContent = ev.title;
|
||||
bar.innerHTML = (ev.is_birthday ? birthdayIconSvg() : '') + escHtml(eventTitle(ev));
|
||||
bar.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
popup.remove();
|
||||
@@ -314,7 +315,7 @@ function showOverflowPopup(anchor, date, events, onEventClick) {
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'mop-title';
|
||||
title.textContent = ev.title;
|
||||
title.innerHTML = (ev.is_birthday ? birthdayIconSvg() : '') + escHtml(eventTitle(ev));
|
||||
|
||||
row.append(dot, time, title);
|
||||
row.addEventListener('click', e => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isToday, isPast, dayOfWeek } from '../utils.js';
|
||||
import { isToday, isPast, dayOfWeek, eventTitle } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
|
||||
export function renderQuarter(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
|
||||
@@ -64,7 +64,7 @@ export function renderQuarter(container, currentDate, events, onDayClick, onEven
|
||||
const dots = cellEvs.slice(0, 3).map(ev => {
|
||||
const color = ev.color || ev.calendarColor || '#4285f4';
|
||||
const pastCls = isPast(ev) ? 'past' : '';
|
||||
return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(ev.title)}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`;
|
||||
return `<span class="qtr-dot ${pastCls}" style="background:${color}" title="${escAttr(eventTitle(ev))}" data-id="${ev.id}" data-url="${escAttr(ev.url || '')}"></span>`;
|
||||
}).join('');
|
||||
const moreDot = cellEvs.length > 3
|
||||
? `<span class="qtr-dot-more">+${cellEvs.length - 3}</span>`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
|
||||
import { t } from '../i18n.js';
|
||||
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber, eventTitle, birthdayIconSvg } from '../utils.js';
|
||||
import { t, getLocale } from '../i18n.js';
|
||||
|
||||
export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday', hourH = 60) {
|
||||
// Build the days array (7 days for week, 1 for day)
|
||||
@@ -77,11 +77,12 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
||||
const cL = evStart < firstDay ? 'continues-left' : '';
|
||||
const cR = (ev.allDay ? evEnd > lastDay : evEnd > lastDayMidnight) ? 'continues-right' : '';
|
||||
const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
|
||||
? `${fmtTime(new Date(ev.start))} ${ev.title}`
|
||||
: ev.title;
|
||||
? `${fmtTime(new Date(ev.start))} ${eventTitle(ev)}`
|
||||
: eventTitle(ev);
|
||||
const icon = ev.is_birthday ? birthdayIconSvg() : '';
|
||||
return `<div class="allday-span ${pastCls} ${multiCls} ${cL} ${cR}"
|
||||
style="left:calc(${left.toFixed(2)}% + 1px);width:calc(${width.toFixed(2)}% - 2px);top:${top}px;background:${color};color:#fff"
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(label)}</div>`;
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(eventTitle(ev))}">${icon}${escHtml(label)}</div>`;
|
||||
}).join('');
|
||||
|
||||
const alldayBgCols = days.map(day =>
|
||||
@@ -129,11 +130,12 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
|
||||
const isShort = height < 34;
|
||||
const shortCls = isShort ? 'short' : '';
|
||||
const locHtml = (!isShort && ev.location) ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
|
||||
const icon = ev.is_birthday ? birthdayIconSvg() : '';
|
||||
return `<div class="timed-event ${pastCls} ${shortCls}"
|
||||
style="top:${top}px;height:${height}px;left:${left}%;width:${width}%;background:${color};color:#fff"
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">
|
||||
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(eventTitle(ev))}">
|
||||
<div class="ev-time">${startStr}</div>
|
||||
<div class="ev-title">${escHtml(ev.title)}</div>
|
||||
<div class="ev-title">${icon}${escHtml(eventTitle(ev))}</div>
|
||||
${locHtml}
|
||||
</div>`;
|
||||
}).join('');
|
||||
@@ -350,7 +352,7 @@ function isSameDay(a, b) {
|
||||
}
|
||||
|
||||
function fmtTime(d) {
|
||||
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
|
||||
return d.toLocaleTimeString(getLocale(), { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function escHtml(s) {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Calendarr",
|
||||
"short_name": "Calendarr",
|
||||
"description": "Dein privater, selbst gehosteter Kalender.",
|
||||
"lang": "de",
|
||||
"dir": "ltr",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0e0e14",
|
||||
"theme_color": "#4285f4",
|
||||
"theme_color": "#16713d",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
@@ -21,10 +26,16 @@
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
"src": "/icons/icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// the entry HTML / version files). New releases take effect on the next
|
||||
// reload, no manual SW unregister required.
|
||||
|
||||
const CACHE_VERSION = 'calendarr-v24';
|
||||
const CACHE_VERSION = 'calendarr-v36';
|
||||
const OFFLINE_SHELL = ['/', '/index.html'];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
|
||||