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

+
+ + +

Only affects the Week and Month views.

+
+ + +
+

+ Uses the same album configured on the Photos tab -- nothing extra to set up.

+ +
+ +

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 %}

    + {% endif %} +
  • + {% endfor %} +
+ + {% if frame.calendar_fetch_summary %} +

Last fetch: {{ frame.calendar_fetch_summary }}

+ {% endif %} +
+
+ +
+
+

Preview

+

How this frame's calendar currently renders.

+ Calendar preview + +
+
+
+ +
+{% endblock %} + +{% block scripts %} + + + + +{% endblock %} diff --git a/server/app/templates/frame_config.html b/server/app/templates/frame_config.html index 9e309ec..b06ce44 100644 --- a/server/app/templates/frame_config.html +++ b/server/app/templates/frame_config.html @@ -1,7 +1,8 @@ {% extends "app_base.html" %} {% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %} -{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% 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 %} @@ -17,15 +18,6 @@

Display settings

- - -
- - -
-

- Uses the same album configured on the Photos tab -- nothing extra to set up.

- -
- -

Included calendars

-

Each linked person decides whether their own calendar - contributes to this frame -- being linked here doesn't include it - automatically.

- - - {% if frame.calendar_fetch_summary %} -

Last fetch: {{ frame.calendar_fetch_summary }}

- {% endif %} - -

Preview

-

How this frame's calendar currently renders.

- Calendar preview - -
@@ -267,5 +206,6 @@ window.DEFAULT_PALETTE_HEX = {{ palette_to_hex(default_palette_rgb) | tojson }}; + {% endblock %} diff --git a/server/app/templates/frame_photos.html b/server/app/templates/frame_photos.html index 96fa794..9cad862 100644 --- a/server/app/templates/frame_photos.html +++ b/server/app/templates/frame_photos.html @@ -1,7 +1,8 @@ {% extends "app_base.html" %} {% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %} -{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% 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 %} @@ -57,6 +58,7 @@ {% block scripts %} + {% endblock %} diff --git a/server/app/templates/frame_stats.html b/server/app/templates/frame_stats.html index cc89918..f555158 100644 --- a/server/app/templates/frame_stats.html +++ b/server/app/templates/frame_stats.html @@ -1,7 +1,8 @@ {% extends "app_base.html" %} {% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %} -{% block page_title %}{{ frame.name or "Frame " ~ frame.id }}{% 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 %} @@ -24,5 +25,6 @@ + {% endblock %}