diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py
index 662b681..ad47935 100644
--- a/server/app/routers/frame_pages.py
+++ b/server/app/routers/frame_pages.py
@@ -1,7 +1,7 @@
-"""The per-frame HTML pages: Photos (/frames/{id}), Configuration, and
-Stats tabs, all inside the sidebar app shell. Data loading happens
-client-side against /api/frames/{id}/... (routers/api_frames.py); these
-routes just authorize and render the scaffold."""
+"""The per-frame HTML pages: Photos (/frames/{id}), Configuration,
+Calendar, and Stats tabs, all inside the sidebar app shell. Data loading
+happens client-side against /api/frames/{id}/... (routers/api_frames.py);
+these routes just authorize and render the scaffold."""
from __future__ import annotations
@@ -73,8 +73,20 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
default_palette_rgb=DEFAULT_PALETTE_RGB,
palette_to_hex=palette_to_hex,
display_mode_labels=DISPLAY_MODE_LABELS,
+ )
+
+
+WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
+ 4: "Friday", 5: "Saturday", 6: "Sunday"}
+
+
+@router.get("/frames/{frame_id}/calendar", response_class=HTMLResponse)
+def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
+ return _frame_page(
+ request, db, frame_id, "frame_calendar.html", "calendar",
calendar_views=CALENDAR_VIEW_LABELS,
calendar_users=_calendar_users_for_frame(db, frame_id),
+ week_start_labels=WEEK_START_LABELS,
)
diff --git a/server/app/static/frame_calendar.js b/server/app/static/frame_calendar.js
new file mode 100644
index 0000000..5fc4ee1
--- /dev/null
+++ b/server/app/static/frame_calendar.js
@@ -0,0 +1,82 @@
+// Calendar tab: view/week-start/photo-inlay settings, per-user opt-in,
+// and the rendered preview. Extracted from frame_config.js when the
+// Calendar card became its own tab (window.FRAME_API is set by the
+// template; checkboxes are always sent explicitly as "true"/"false").
+
+document.getElementById('calendar-config-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const body = new URLSearchParams({
+ calendar_view: document.getElementById('calendar_view').value,
+ calendar_week_start: document.getElementById('calendar_week_start').value,
+ calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked),
+ });
+ try {
+ const resp = await fetch(`${window.FRAME_API}/config`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ });
+ if (!resp.ok) throw new Error(await apiError(resp));
+ showStatus(true, 'Saved.');
+ loadCalendarPreview();
+ } catch (e) {
+ showStatus(false, e.message);
+ }
+});
+
+// Each person's own opt-in -- auto-saves on toggle, not batched into the
+// form above, since it's the toggling user's own preference (see
+// api_frames.py's /calendar-included), not a frame-wide setting.
+document.querySelectorAll('.calendar-self-toggle').forEach((el) => {
+ el.addEventListener('change', async () => {
+ try {
+ const resp = await fetch(`${window.FRAME_API}/calendar-included`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ included: el.checked }),
+ });
+ if (!resp.ok) throw new Error(await apiError(resp));
+ showStatus(true, el.checked ? 'Your calendar is included on this frame.' : 'Your calendar removed from this frame.');
+ } catch (e) {
+ el.checked = !el.checked;
+ showStatus(false, e.message);
+ }
+ });
+});
+
+function loadCalendarPreview() {
+ document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
+}
+document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
+loadCalendarPreview();
+
+async function takeControl() {
+ try {
+ const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
+ if (!resp.ok) throw new Error(await apiError(resp));
+ showStatus(true, 'You have control now.');
+ loadControl();
+ } catch (e) {
+ showStatus(false, e.message);
+ }
+}
+
+async function loadControl() {
+ const banner = document.getElementById('control-banner');
+ try {
+ const resp = await fetch(`${window.FRAME_API}/queue`);
+ if (!resp.ok) return; // unconfigured frame: control still works via 409s
+ const data = await resp.json();
+ if (data.control && !data.control.you) {
+ banner.style.display = 'flex';
+ document.getElementById('control-holder').textContent = data.control.controller
+ ? `${data.control.controller} currently has control of this frame.`
+ : 'Nobody has control of this frame yet.';
+ } else {
+ banner.style.display = 'none';
+ }
+ } catch (e) { /* banner is best-effort */ }
+}
+
+document.getElementById('take-control').addEventListener('click', takeControl);
+loadControl();
diff --git a/server/app/static/frame_config.js b/server/app/static/frame_config.js
index ccefdb2..cd40c97 100644
--- a/server/app/static/frame_config.js
+++ b/server/app/static/frame_config.js
@@ -1,13 +1,13 @@
// Configuration tab: frame settings + firmware card + take control.
-// window.FRAME_API is set by the template. Checkboxes are always sent
+// Frame name/mode live in the page header now (frame_header.js) and
+// Calendar settings have their own tab (frame_calendar.js). window.
+// FRAME_API is set by the template. Checkboxes are always sent
// explicitly as "true"/"false" -- the server treats absent fields as
// "leave unchanged", so a checkbox must never be simply omitted.
async function saveConfig() {
const minutes = parseInt(document.getElementById('refresh_interval_minutes').value, 10) || 60;
const body = new URLSearchParams({
- mode: document.getElementById('frame_mode').value,
- name: document.getElementById('frame_name').value || '',
order: document.getElementById('order').value,
orientation: document.getElementById('orientation').value,
refresh_interval_s: String(minutes * 60),
@@ -37,69 +37,6 @@ document.getElementById('config-form').addEventListener('submit', async (e) => {
}
});
-// ---- Calendar card: mode/view toggling, its own save, self opt-in, preview ----
-
-const calendarCard = document.getElementById('calendar-card');
-if (calendarCard) {
- document.getElementById('frame_mode').addEventListener('change', () => {
- calendarCard.style.display = document.getElementById('frame_mode').value === 'calendar' ? 'block' : 'none';
- });
-
- const inlayRow = document.getElementById('calendar-inlay-row');
- const inlayHint = document.getElementById('calendar-inlay-hint');
- document.getElementById('calendar_view').addEventListener('change', () => {
- const isAgenda = document.getElementById('calendar_view').value === 'agenda';
- inlayRow.style.display = isAgenda ? 'flex' : 'none';
- inlayHint.style.display = isAgenda ? 'block' : 'none';
- });
-
- document.getElementById('calendar-config-form').addEventListener('submit', async (e) => {
- e.preventDefault();
- const body = new URLSearchParams({
- calendar_view: document.getElementById('calendar_view').value,
- calendar_photo_inlay: String(document.getElementById('calendar_photo_inlay').checked),
- });
- try {
- const resp = await fetch(`${window.FRAME_API}/config`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body,
- });
- if (!resp.ok) throw new Error(await apiError(resp));
- showStatus(true, 'Saved.');
- loadCalendarPreview();
- } catch (e) {
- showStatus(false, e.message);
- }
- });
-
- // Each person's own opt-in -- auto-saves on toggle, not batched into
- // the form above, since it's the toggling user's own preference (see
- // api_frames.py's /calendar-included), not a frame-wide setting.
- document.querySelectorAll('.calendar-self-toggle').forEach((el) => {
- el.addEventListener('change', async () => {
- try {
- const resp = await fetch(`${window.FRAME_API}/calendar-included`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ included: el.checked }),
- });
- if (!resp.ok) throw new Error(await apiError(resp));
- showStatus(true, el.checked ? 'Your calendar is included on this frame.' : 'Your calendar removed from this frame.');
- } catch (e) {
- el.checked = !el.checked;
- showStatus(false, e.message);
- }
- });
- });
-
- function loadCalendarPreview() {
- document.getElementById('calendar-preview').src = `${window.FRAME_API}/preview/calendar?_=${Date.now()}`;
- }
- document.getElementById('calendar-preview-refresh').addEventListener('click', loadCalendarPreview);
- loadCalendarPreview();
-}
-
async function takeControl() {
try {
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
diff --git a/server/app/static/frame_header.js b/server/app/static/frame_header.js
new file mode 100644
index 0000000..2cd2c97
--- /dev/null
+++ b/server/app/static/frame_header.js
@@ -0,0 +1,82 @@
+// Page-header controls shared by every per-frame page (Photos/
+// Configuration/Calendar/Stats): the frame-name pencil-edit and the
+// mode selector, both now living outside the tab structure since they
+// apply regardless of which tab is open. Depends on window.FRAME_API
+// (set per-page) and common.js's showStatus/apiError.
+
+(function () {
+ var view = document.getElementById('frame-name-view');
+ var editRow = document.getElementById('frame-name-edit-row');
+ var pencil = document.getElementById('frame-name-pencil');
+ var input = document.getElementById('frame-name-input');
+ var textEl = document.getElementById('frame-name-text');
+ var saveBtn = document.getElementById('frame-name-save');
+ var cancelBtn = document.getElementById('frame-name-cancel');
+ if (!view || !window.FRAME_API) return;
+
+ function openEdit() {
+ input.value = textEl.textContent.trim();
+ view.style.display = 'none';
+ editRow.style.display = 'inline-flex';
+ input.focus();
+ input.select();
+ }
+ function closeEdit() {
+ editRow.style.display = 'none';
+ view.style.display = 'inline-flex';
+ }
+
+ pencil.addEventListener('click', openEdit);
+ cancelBtn.addEventListener('click', closeEdit);
+
+ async function save() {
+ var name = input.value.trim();
+ if (!name || name === textEl.textContent.trim()) {
+ closeEdit();
+ return;
+ }
+ try {
+ const resp = await fetch(`${window.FRAME_API}/config`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ name }),
+ });
+ if (!resp.ok) throw new Error(await apiError(resp));
+ textEl.textContent = name;
+ closeEdit();
+ showStatus(true, 'Renamed.');
+ } catch (e) {
+ showStatus(false, e.message);
+ }
+ }
+ saveBtn.addEventListener('click', save);
+ input.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter') save();
+ if (e.key === 'Escape') closeEdit();
+ });
+})();
+
+(function () {
+ var sel = document.getElementById('frame-mode-select');
+ if (!sel || !window.FRAME_API) return;
+ var previous = sel.value;
+
+ sel.addEventListener('change', async function () {
+ var mode = sel.value;
+ try {
+ const resp = await fetch(`${window.FRAME_API}/config`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ mode }),
+ });
+ if (!resp.ok) throw new Error(await apiError(resp));
+ previous = mode;
+ showStatus(true, mode === 'calendar' ? 'Switched to Calendar mode.' : 'Switched to Photos mode.');
+ var calTab = document.querySelector('.tabs a[href$="/calendar"]');
+ if (calTab) calTab.classList.toggle('tab-disabled', mode !== 'calendar');
+ } catch (e) {
+ sel.value = previous;
+ showStatus(false, e.message);
+ }
+ });
+})();
diff --git a/server/app/static/theme.css b/server/app/static/theme.css
index 8960e05..9c0b054 100644
--- a/server/app/static/theme.css
+++ b/server/app/static/theme.css
@@ -468,6 +468,35 @@ code {
}
.tabs a:hover { color: var(--text); }
.tabs a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
+.tabs a.tab-disabled { opacity: 0.45; }
+.tabs a.tab-disabled:hover { opacity: 0.7; }
+
+.frame-name-view { display: inline-flex; align-items: center; gap: 6px; }
+.frame-name-pencil {
+ background: none;
+ border: none;
+ box-shadow: none;
+ cursor: pointer;
+ font-size: 14px;
+ line-height: 1;
+ padding: 4px;
+ margin: 0;
+ opacity: 0.55;
+ color: var(--text);
+ transition: opacity .12s ease, background-color .12s ease;
+}
+.frame-name-pencil:hover { opacity: 1; background: var(--surface-alt); border-radius: 6px; }
+.frame-name-edit { display: inline-flex; align-items: center; gap: 6px; }
+.frame-name-edit input {
+ width: auto;
+ margin-top: 0;
+ padding: 5px 8px;
+ font-size: 15px;
+ font-weight: 700;
+}
+.frame-name-edit button { margin-top: 0; }
+
+.frame-mode-select { width: auto; margin-top: 0; padding: 7px 10px; font-size: 13px; font-weight: 600; }
.control-banner {
display: flex;
diff --git a/server/app/templates/_frame_mode_select.html b/server/app/templates/_frame_mode_select.html
new file mode 100644
index 0000000..f955961
--- /dev/null
+++ b/server/app/templates/_frame_mode_select.html
@@ -0,0 +1,4 @@
+
diff --git a/server/app/templates/_frame_name_edit.html b/server/app/templates/_frame_name_edit.html
new file mode 100644
index 0000000..473227d
--- /dev/null
+++ b/server/app/templates/_frame_name_edit.html
@@ -0,0 +1,9 @@
+
+ {{ frame.name or ("Frame " ~ frame.id) }}
+
+
+
+
+
+
+
diff --git a/server/app/templates/_frame_tabs.html b/server/app/templates/_frame_tabs.html
index 8df01c3..33406a2 100644
--- a/server/app/templates/_frame_tabs.html
+++ b/server/app/templates/_frame_tabs.html
@@ -1,5 +1,7 @@
diff --git a/server/app/templates/frame_calendar.html b/server/app/templates/frame_calendar.html
new file mode 100644
index 0000000..8aea7af
--- /dev/null
+++ b/server/app/templates/frame_calendar.html
@@ -0,0 +1,99 @@
+{% extends "app_base.html" %}
+
+{% block title %}{{ frame.name or "Frame" }} · Calendar{% endblock %}
+{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
+{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %}
+
+{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
+{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {% if frame.mode != 'calendar' %}
+
This frame is currently in Photos mode --
+ settings below take effect once you switch it to Calendar mode
+ using the selector at the top of the page.
+ {% endif %}
+
+
+
+
+
Calendar
+
+
+
Included calendars
+
Each linked person decides whether their own calendar
+ contributes to this frame -- being linked here doesn't include it
+ automatically.
+
+ {% for u in calendar_users %}
+
+ {% if u.user_id == user.id %}
+ {% if u.has_url %}
+
+ {% else %}
+
{{ u.display_name }} (you) -- no calendar set, add one in Settings.
+ {% endif %}
+ {% else %}
+
{{ u.display_name }}:
+ {% if not u.has_url %}no calendar set{% elif u.included %}included{% else %}not included{% endif %}