Manual per-calendar color choice for calendar mode
Build and push server image / build-and-push (push) Successful in 49s
Build and push server image / build-and-push (push) Successful in 49s
Each linked person can pin one of the panel's four non-black/white colors (Yellow/Red/Blue/Green) to their own calendar instead of relying on calendar_render.py's old auto-cycle-by-owner-name order -- owner-only, like adding a calendar in the first place. Colors resolve against whichever palette a frame actually renders with (including a custom Advanced configuration override), so a pinned "Blue" stays this frame's actual blue. Event color bars/dots are also bigger and rounded now across agenda/week/month views, easier to tell apart at a glance.
This commit is contained in:
@@ -94,13 +94,17 @@ class CalendarSource:
|
||||
"caldav", url is the calendar's own URL, username/password its
|
||||
account credentials) -- see caldav_client.py. owner_display_name
|
||||
tags every event pulled from this source so a merged agenda can show
|
||||
whose event is whose."""
|
||||
whose event is whose. color_index (2-5, into
|
||||
image_pipeline.DEFAULT_PALETTE_RGB) is this calendar's manually
|
||||
pinned color, or None to fall back on calendar_render.py's old
|
||||
auto-cycle-by-owner-name behavior -- see models.FrameCalendar."""
|
||||
|
||||
owner_display_name: str
|
||||
kind: str
|
||||
url: str
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
color_index: int | None = None
|
||||
|
||||
|
||||
def merge_events(
|
||||
@@ -127,6 +131,7 @@ def merge_events(
|
||||
continue
|
||||
for event in events:
|
||||
event["owner_display_name"] = source.owner_display_name
|
||||
event["color_index"] = source.color_index
|
||||
merged.append(event)
|
||||
|
||||
merged.sort(key=lambda e: e["start"])
|
||||
|
||||
@@ -40,14 +40,28 @@ MUTED = (110, 110, 110)
|
||||
# to the 6-color e-ink palette -- black reads as an actual line on-panel.
|
||||
RULE = (0, 0, 0)
|
||||
|
||||
# Cycled per distinct owner_display_name so a merged multi-person calendar
|
||||
# can visually tell whose event is whose -- the panel's own non-black/
|
||||
# white ink colors, skipping black/white (index 0/1 in DEFAULT_PALETTE_RGB)
|
||||
# since those are already the page's text/background.
|
||||
# Fallback for any event whose calendar has no manually pinned color
|
||||
# (event["color_index"] is None): cycled per distinct owner_display_name
|
||||
# so a merged multi-person calendar can still visually tell whose event
|
||||
# is whose -- the panel's own non-black/white ink colors, skipping
|
||||
# black/white (index 0/1 in DEFAULT_PALETTE_RGB) since those are already
|
||||
# the page's text/background.
|
||||
OWNER_COLORS = DEFAULT_PALETTE_RGB[2:]
|
||||
|
||||
|
||||
def _owner_color(owner_display_name: str, owners_seen: list[str]) -> tuple[int, int, int]:
|
||||
def _event_color(event: dict, owners_seen: list[str], palette_rgb: list | None) -> tuple[int, int, int]:
|
||||
"""A specific calendar's manually pinned color (event["color_index"],
|
||||
set from models.FrameCalendar.color_index -- see
|
||||
routers/api_frames.py's api_calendar_color) resolved against
|
||||
whichever palette this frame actually renders with, so a pinned
|
||||
"Blue" still looks like this frame's blue even if its Advanced
|
||||
configuration has retuned the panel's RGB values. Falls back to the
|
||||
old auto-cycle-by-owner-name when a calendar has no color pinned."""
|
||||
color_index = event.get("color_index")
|
||||
if color_index is not None:
|
||||
palette = palette_rgb or DEFAULT_PALETTE_RGB
|
||||
return tuple(palette[color_index])
|
||||
owner_display_name = event["owner_display_name"]
|
||||
if owner_display_name not in owners_seen:
|
||||
owners_seen.append(owner_display_name)
|
||||
return OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)]
|
||||
@@ -262,7 +276,7 @@ def _draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0:
|
||||
|
||||
def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
|
||||
region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
|
||||
body_font: ImageFont.ImageFont, owners_seen: list[str],
|
||||
body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
|
||||
weather_cities: list[dict] | None = None, weather_font: ImageFont.ImageFont | None = None,
|
||||
weather_units: str = "fahrenheit") -> None:
|
||||
"""Draws one day's header + weather strip (if any) + event rows
|
||||
@@ -296,16 +310,17 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
|
||||
if i >= max_rows:
|
||||
draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
|
||||
break
|
||||
color = _owner_color(event["owner_display_name"], owners_seen)
|
||||
draw.rectangle([text_x0, y + 3, text_x0 + 6, y + row_h - 8], fill=color)
|
||||
color = _event_color(event, owners_seen, palette_rgb)
|
||||
draw.rounded_rectangle([text_x0, y + 2, text_x0 + 10, y + row_h - 7], radius=3, fill=color)
|
||||
time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
|
||||
line = f"{time_str} {event['summary']}"
|
||||
draw_text(img, (text_x0 + 16, y), _truncate_to_width(draw, line, body_font, text_w - 16), body_font)
|
||||
draw_text(img, (text_x0 + 18, y), _truncate_to_width(draw, line, body_font, text_w - 18), body_font)
|
||||
y += row_h
|
||||
|
||||
|
||||
def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
||||
photo_inlay: Image.Image | None, weather_cities: list[dict] | None = None,
|
||||
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
|
||||
weather_cities: list[dict] | None = None,
|
||||
weather_units: str = "fahrenheit") -> Image.Image:
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
img = Image.new("RGB", (logical_w, logical_h), BG)
|
||||
@@ -325,13 +340,14 @@ def _build_agenda(events: list[dict], browse_offset: int, orientation: str, tz:
|
||||
day = datetime.now(tz).date() + timedelta(days=browse_offset)
|
||||
owners_seen: list[str] = []
|
||||
_draw_agenda_day(img, draw, day, events, tz, (cx0, cy0, cw, ch), title_font, body_font, owners_seen,
|
||||
weather_cities, weather_font, weather_units)
|
||||
palette_rgb, weather_cities, weather_font, weather_units)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
||||
photo_inlay: Image.Image | None, weather_cities: list[dict] | None = None,
|
||||
photo_inlay: Image.Image | None, palette_rgb: list | None = None,
|
||||
weather_cities: list[dict] | None = None,
|
||||
weather_units: str = "fahrenheit") -> Image.Image:
|
||||
"""Two _draw_agenda_day sections stacked vertically within the content
|
||||
region (below each other rather than side-by-side -- narrower than
|
||||
@@ -359,13 +375,14 @@ def _build_today_tomorrow(events: list[dict], browse_offset: int, orientation: s
|
||||
draw.line([(cx0 + MARGIN, section_y0), (cx0 + cw - MARGIN, section_y0)], fill=RULE)
|
||||
_draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
|
||||
(cx0, section_y0, cw, section_h), title_font, body_font, owners_seen,
|
||||
weather_cities, weather_font, weather_units)
|
||||
palette_rgb, weather_cities, weather_font, weather_units)
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
||||
photo_inlay: Image.Image | None, week_start: int, weather_cities: list[dict] | None = None,
|
||||
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None,
|
||||
weather_cities: list[dict] | None = None,
|
||||
weather_units: str = "fahrenheit") -> Image.Image:
|
||||
logical_w, logical_h = logical_render_size(orientation)
|
||||
img = Image.new("RGB", (logical_w, logical_h), BG)
|
||||
@@ -409,17 +426,17 @@ def _build_week(events: list[dict], browse_offset: int, orientation: str, tz: Zo
|
||||
if i >= max_rows:
|
||||
draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED)
|
||||
break
|
||||
color = _owner_color(event["owner_display_name"], owners_seen)
|
||||
draw.rectangle([x0 + 4, y + 2, x0 + 8, y + row_h - 6], fill=color)
|
||||
color = _event_color(event, owners_seen, palette_rgb)
|
||||
draw.rounded_rectangle([x0 + 4, y + 1, x0 + 11, y + row_h - 5], radius=2, fill=color)
|
||||
text = event["summary"] if event["all_day"] else f"{_fmt_time(_event_start(event, tz))[:-3]} {event['summary']}"
|
||||
draw_text(img, (x0 + 14, y), _truncate_to_width(draw, text, chip_font, col_w - 18), chip_font)
|
||||
draw_text(img, (x0 + 16, y), _truncate_to_width(draw, text, chip_font, col_w - 20), chip_font)
|
||||
y += row_h
|
||||
|
||||
return img
|
||||
|
||||
|
||||
def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: ZoneInfo,
|
||||
photo_inlay: Image.Image | None, week_start: int) -> Image.Image:
|
||||
photo_inlay: Image.Image | None, week_start: int, palette_rgb: list | None = None) -> Image.Image:
|
||||
"""Density dots per day, not literal event text -- real text at
|
||||
typical month-cell size (~100x70px) is close to unreadable on a
|
||||
6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
|
||||
@@ -447,27 +464,27 @@ def _build_month(events: list[dict], browse_offset: int, orientation: str, tz: Z
|
||||
draw_text(img, (cx0 + MARGIN + col * col_w + 6, cy0 + MARGIN), name[:3], header_font, MUTED)
|
||||
|
||||
owners_seen: list[str] = []
|
||||
dot_r = 4
|
||||
dot_r = 6
|
||||
for row, week in enumerate(weeks):
|
||||
for col, day in enumerate(week):
|
||||
x0 = cx0 + MARGIN + col * col_w
|
||||
y0 = grid_top + row * row_h
|
||||
draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
|
||||
in_month = day.month == target_month.month
|
||||
color = FG if in_month else MUTED
|
||||
text_color = FG if in_month else MUTED
|
||||
if day == today:
|
||||
draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
|
||||
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, color)
|
||||
draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, text_color)
|
||||
|
||||
day_events = _events_on_day(events, day, tz)
|
||||
dot_x = x0 + 8
|
||||
dot_y = y0 + row_h - 14
|
||||
dot_y = y0 + row_h - dot_r * 2 - 6
|
||||
for i, event in enumerate(day_events[:4]):
|
||||
color = _owner_color(event["owner_display_name"], owners_seen)
|
||||
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=color)
|
||||
dot_x += dot_r * 2 + 4
|
||||
event_color = _event_color(event, owners_seen, palette_rgb)
|
||||
draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
|
||||
dot_x += dot_r * 2 + 5
|
||||
if len(day_events) > 4:
|
||||
draw_text(img, (dot_x, dot_y - 4), f"+{len(day_events) - 4}", header_font, MUTED)
|
||||
draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font, MUTED)
|
||||
|
||||
return img
|
||||
|
||||
@@ -478,21 +495,29 @@ _BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "
|
||||
|
||||
def _build(events: list[dict], view: str, browse_offset: int, orientation: str, timezone: str,
|
||||
photo_inlay: Image.Image | None, fetch_summary: str, week_start: int,
|
||||
palette_rgb: list | None = None,
|
||||
weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit") -> Image.Image:
|
||||
tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
|
||||
if view == "agenda":
|
||||
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, weather_cities, weather_units)
|
||||
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
||||
weather_cities, weather_units)
|
||||
elif view == "today_tomorrow":
|
||||
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay, weather_cities, weather_units)
|
||||
img = _build_today_tomorrow(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
||||
weather_cities, weather_units)
|
||||
elif view == "week":
|
||||
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, weather_cities, weather_units)
|
||||
img = _build_week(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb,
|
||||
weather_cities, weather_units)
|
||||
elif view == "month":
|
||||
# Never given weather -- no room for it at typical month-cell size,
|
||||
# same reasoning that already keeps this view to density dots
|
||||
# instead of literal event text (see _build_month's own docstring).
|
||||
img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start)
|
||||
# Colors are still passed through, though -- that's a different
|
||||
# concern (legibility of individual events) than weather's
|
||||
# (space for a whole extra strip of content).
|
||||
img = _build_month(events, browse_offset, orientation, tz, photo_inlay, week_start, palette_rgb)
|
||||
else:
|
||||
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, weather_cities, weather_units)
|
||||
img = _build_agenda(events, browse_offset, orientation, tz, photo_inlay, palette_rgb,
|
||||
weather_cities, weather_units)
|
||||
|
||||
if fetch_summary:
|
||||
font = ImageFont.load_default(size=14)
|
||||
@@ -512,7 +537,7 @@ def render_calendar(events: list[dict], view: str, browse_offset: int, orientati
|
||||
get_or_refresh_weather() cache, or None/[] to omit the weather strip
|
||||
entirely (also always omitted for view == "month")."""
|
||||
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start,
|
||||
weather_cities, weather_units)
|
||||
palette_rgb, weather_cities, weather_units)
|
||||
img = _apply_manage_overlay(img, manage)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
return _transpose_and_pack(quantized, orientation)
|
||||
@@ -526,7 +551,7 @@ def render_calendar_preview_png(events: list[dict], view: str, browse_offset: in
|
||||
PNG in logical (upright) orientation -- mirrors
|
||||
image_pipeline.render_preview_png's relationship to render_frame."""
|
||||
img = _build(events, view, browse_offset, orientation, timezone, photo_inlay, fetch_summary, week_start,
|
||||
weather_cities, weather_units)
|
||||
palette_rgb, weather_cities, weather_units)
|
||||
img = _apply_manage_overlay(img, manage)
|
||||
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
|
||||
buf = io.BytesIO()
|
||||
|
||||
@@ -160,6 +160,18 @@ def _migration_10(conn) -> None:
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_weather_cached TEXT"))
|
||||
|
||||
|
||||
def _migration_11(conn) -> None:
|
||||
"""Manual per-calendar color choice (frame_calendars.color_index,
|
||||
2-5 into image_pipeline.DEFAULT_PALETTE_RGB -- Yellow/Red/Blue/
|
||||
Green). calendar_render.py's event color bar/dot used to auto-cycle
|
||||
through those same four colors in whatever order calendars happened
|
||||
to appear; this lets a household pin a specific one instead so it
|
||||
stays stable and recognizable. NULL (the default) keeps the old
|
||||
auto-cycle behavior -- no existing frame's render changes until
|
||||
someone actually picks a color."""
|
||||
conn.execute(text("ALTER TABLE frame_calendars ADD COLUMN color_index INTEGER"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -171,6 +183,7 @@ MIGRATIONS = [
|
||||
(8, _migration_8),
|
||||
(9, _migration_9),
|
||||
(10, _migration_10),
|
||||
(11, _migration_11),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -291,6 +291,13 @@ class FrameCalendar(Base):
|
||||
# if the owner's CalDAV account later stops offering this calendar.
|
||||
calendar_label: Mapped[str] = mapped_column(String, default="")
|
||||
included: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
# Index into image_pipeline.DEFAULT_PALETTE_RGB/PALETTE_LABELS (2-5:
|
||||
# Yellow/Red/Blue/Green -- 0/1 are reserved, already the page's
|
||||
# text/background) pinning this calendar's events to a specific
|
||||
# panel color rather than calendar_render.py's old owner-name
|
||||
# auto-cycle. NULL keeps the auto-cycle behavior. Only the calendar's
|
||||
# owner may set this -- see routers/api_frames.py's api_calendar_color.
|
||||
color_index: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_frame_calendars_unique", "frame_id", "user_id", "calendar_key", unique=True),
|
||||
|
||||
@@ -469,6 +469,45 @@ def api_calendar_select(
|
||||
return {"status": "saved", "included": row.included}
|
||||
|
||||
|
||||
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
|
||||
|
||||
|
||||
class CalendarColorRequest(BaseModel):
|
||||
calendar_key: str
|
||||
color_index: int | None # None clears the pin, reverting to auto-cycle
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/calendar-color")
|
||||
def api_calendar_color(
|
||||
body: CalendarColorRequest,
|
||||
request: Request,
|
||||
frame: Frame = Depends(require_frame_view),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Pins a specific panel color to one of your own included calendars
|
||||
(models.FrameCalendar.color_index) -- always owner-only, unlike
|
||||
calendar-select's included=False, since recoloring someone else's
|
||||
calendar isn't the same kind of "I'd rather not see this" veto as
|
||||
muting it. None clears the pin, reverting calendar_render.py to its
|
||||
old auto-cycle-by-owner-name behavior for this calendar."""
|
||||
user = require_user_api(request, db)
|
||||
if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
|
||||
raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
|
||||
row = db.execute(
|
||||
select(FrameCalendar).where(
|
||||
FrameCalendar.frame_id == frame.id,
|
||||
FrameCalendar.user_id == user.id,
|
||||
FrameCalendar.calendar_key == body.calendar_key,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not included on this frame")
|
||||
row.color_index = body.color_index
|
||||
frame.calendar_checked_at = 0.0
|
||||
db.commit()
|
||||
return {"status": "saved", "color_index": row.color_index}
|
||||
|
||||
|
||||
def _calendar_photo_inlay(frame: Frame, db: Session):
|
||||
"""The photo-inlay's source image (any view now, not just agenda), or
|
||||
None if inlay is off or the frame's photos-mode album isn't
|
||||
|
||||
@@ -389,11 +389,14 @@ def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.
|
||||
name = u.display_name or u.username
|
||||
if fc.calendar_key == "ics":
|
||||
if u.calendar_ics_url:
|
||||
sources.append(calendar_feed.CalendarSource(name, "ics", u.calendar_ics_url))
|
||||
sources.append(calendar_feed.CalendarSource(
|
||||
name, "ics", u.calendar_ics_url, color_index=fc.color_index
|
||||
))
|
||||
elif fc.calendar_key.startswith("caldav:") and u.calendar_caldav_username:
|
||||
href = fc.calendar_key[len("caldav:"):]
|
||||
sources.append(calendar_feed.CalendarSource(
|
||||
name, "caldav", href, u.calendar_caldav_username, u.calendar_caldav_password
|
||||
name, "caldav", href, u.calendar_caldav_username, u.calendar_caldav_password,
|
||||
color_index=fc.color_index,
|
||||
))
|
||||
return sources
|
||||
|
||||
|
||||
@@ -78,9 +78,10 @@ def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None)
|
||||
for u in users:
|
||||
is_self = u.id == viewer_id
|
||||
if is_self:
|
||||
included = {fc.calendar_key: fc.included for fc in included_by_user.get(u.id, [])}
|
||||
own_rows = {fc.calendar_key: fc for fc in included_by_user.get(u.id, [])}
|
||||
calendars = [
|
||||
{**c, "included": included.get(c["key"], False)}
|
||||
{**c, "included": own_rows[c["key"]].included if c["key"] in own_rows else False,
|
||||
"color_index": own_rows[c["key"]].color_index if c["key"] in own_rows else None}
|
||||
for c in _user_available_calendars(u)
|
||||
]
|
||||
else:
|
||||
@@ -119,6 +120,9 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g
|
||||
calendar_views=CALENDAR_VIEW_LABELS,
|
||||
calendar_users=_calendar_users_for_frame(db, frame_id, viewer.id if viewer else None),
|
||||
week_start_labels=WEEK_START_LABELS,
|
||||
calendar_color_labels=PALETTE_LABELS,
|
||||
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
||||
palette_to_hex=palette_to_hex,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,33 @@ document.querySelectorAll('.calendar-toggle').forEach((el) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Per-calendar color pin -- owner-only (the server enforces it; these
|
||||
// buttons only ever render for the viewer's own calendars anyway, see
|
||||
// frame_calendar.html). Clicking the currently-selected swatch again has
|
||||
// no special "toggle off" behavior -- use the explicit Auto button.
|
||||
document.querySelectorAll('.calendar-color-picker').forEach((picker) => {
|
||||
const key = picker.dataset.key;
|
||||
picker.querySelectorAll('.color-swatch').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const colorIndex = btn.dataset.index === '' ? null : Number(btn.dataset.index);
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/calendar-color`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ calendar_key: key, color_index: colorIndex }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
picker.querySelectorAll('.color-swatch').forEach((el) => el.classList.remove('selected'));
|
||||
btn.classList.add('selected');
|
||||
showStatus(true, 'Color saved.');
|
||||
loadCalendarPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('weather-config-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = new URLSearchParams({
|
||||
|
||||
@@ -265,6 +265,19 @@ input:focus, select:focus {
|
||||
.calendar-user-list > li { margin-top: 14px; }
|
||||
.calendar-user-list > li:first-child { margin-top: 0; }
|
||||
.calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.calendar-row { flex-wrap: wrap; }
|
||||
.calendar-color-picker { display: inline-flex; align-items: center; gap: 5px; margin-left: 8px; }
|
||||
.color-swatch {
|
||||
width: 20px; height: 20px; padding: 0; margin: 0;
|
||||
border: 2px solid var(--border); border-radius: 5px;
|
||||
box-shadow: none; cursor: pointer;
|
||||
}
|
||||
.color-swatch.selected { border-color: var(--text); box-shadow: 0 0 0 1.5px var(--text); }
|
||||
.color-swatch-auto {
|
||||
width: auto; height: 20px; padding: 0 6px; font-size: 10px; font-weight: 600;
|
||||
color: var(--text-muted); background: var(--surface-alt);
|
||||
}
|
||||
.color-swatch-auto.selected { color: var(--text); }
|
||||
|
||||
button {
|
||||
margin-top: 20px;
|
||||
|
||||
@@ -60,13 +60,26 @@
|
||||
<li>
|
||||
<p class="calendar-user-name">{{ u.display_name }}{% if u.is_self %} (you){% endif %}</p>
|
||||
{% if u.calendars %}
|
||||
{% set current_palette = frame.palette_rgb or default_palette_rgb %}
|
||||
{% set current_hex = palette_to_hex(current_palette) %}
|
||||
{% for c in u.calendars %}
|
||||
<label class="checkbox-row" style="margin-top: 6px;">
|
||||
<div class="checkbox-row calendar-row" style="margin-top: 6px;">
|
||||
<input type="checkbox" class="calendar-toggle"
|
||||
data-user-id="{{ u.user_id }}" data-key="{{ c.key }}" data-label="{{ c.label }}"
|
||||
{% if c.included %}checked{% endif %}>
|
||||
{{ c.label }}
|
||||
</label>
|
||||
<label style="margin: 0; font-weight: normal;">{{ c.label }}</label>
|
||||
{% if u.is_self %}
|
||||
<span class="calendar-color-picker" data-key="{{ c.key }}">
|
||||
{% for idx in range(2, 6) %}
|
||||
<button type="button" class="color-swatch {% if c.color_index == idx %}selected{% endif %}"
|
||||
data-index="{{ idx }}" title="{{ calendar_color_labels[idx] }}"
|
||||
style="background-color: {{ current_hex[idx] }};"></button>
|
||||
{% endfor %}
|
||||
<button type="button" class="color-swatch color-swatch-auto {% if c.color_index is none %}selected{% endif %}"
|
||||
data-index="" title="Auto (assigned automatically)">Auto</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% elif u.is_self %}
|
||||
<p class="sub" style="margin-top: 6px;">No calendars set up yet -- add an ICS link or CalDAV account in <a href="/settings">Settings</a>.</p>
|
||||
|
||||
Reference in New Issue
Block a user