diff --git a/avian/api/birdnet-api.php b/avian/api/birdnet-api.php index f2a20b4..b33c379 100644 --- a/avian/api/birdnet-api.php +++ b/avian/api/birdnet-api.php @@ -184,6 +184,64 @@ switch ($action) { break; } + + case 'frame_config': { + $cfg_path = __DIR__ . '/frame-config.json'; + $defaults = [ + 'mode' => 'today', + 'hours' => 24, + 'start_time' => '06:00', + 'hold_overnight' => true, + 'species_source' => '', + 'zip' => '', + 'bw_days' => 7, + 'bw_country' => 'us', + 'image_url' => '', + 'shoot' => true, + 'shoot_title' => 'avian visitors', + 'shoot_subtitle' => 'Heard Today', + 'shoot_headline_px' => 42, + 'shoot_eyebrow_px' => 18, + 'shoot_lowercase' => false, + 'shoot_mat' => 0.04, + 'shoot_small_floor' => 0.04, + 'shoot_count_exp' => 0.65, + 'mat' => 0.0, + 'mat_opening_w' => 0, + 'mat_opening_h' => 0, + 'rotate' => 90, + 'saturation' => 0.6, + 'panel' => '', + 'quiet_start' => 22, + 'quiet_end' => 6, + 'heal_hours' => 24, + 'timeout' => 45, + 'basic_user' => '', + 'basic_pass' => '', + ]; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $input = json_decode(file_get_contents('php://input'), true); + if (!is_array($input)) { + http_response_code(400); + echo json_encode(['error' => 'invalid JSON']); + break; + } + $current = file_exists($cfg_path) ? json_decode(file_get_contents($cfg_path), true) : $defaults; + if (!is_array($current)) $current = $defaults; + $allowed = array_keys($defaults); + foreach ($input as $k => $v) { + if (in_array($k, $allowed, true)) $current[$k] = $v; + } + file_put_contents($cfg_path, json_encode($current, JSON_PRETTY_PRINT) . "\n"); + echo json_encode($current); + } else { + $current = file_exists($cfg_path) ? json_decode(file_get_contents($cfg_path), true) : $defaults; + if (!is_array($current)) $current = $defaults; + echo json_encode(array_merge($defaults, $current)); + } + break; + } + default: http_response_code(404); echo json_encode(['error' => 'unknown action']); diff --git a/avian/api/menu.php b/avian/api/menu.php index d1138af..b7c6122 100644 --- a/avian/api/menu.php +++ b/avian/api/menu.php @@ -31,6 +31,7 @@ if (getenv('AV_REQUIRE_AUTH') === '1' && empty($_SERVER['HTTP_AUTHORIZATION'])) echo json_encode([ 'items' => [ ['label' => 'settings', 'href' => '/#admin=settings', 'native' => true], + ['label' => 'frame', 'href' => '/#admin=frame', 'native' => true], ['label' => 'system', 'href' => '/#admin=system', 'native' => true], ['label' => 'logs', 'href' => '/#admin=logs', 'native' => true], ['label' => 'tools', 'href' => '/#admin=tools', 'native' => true], diff --git a/avian/frontend/apt.js b/avian/frontend/apt.js index 05504b0..5b18827 100644 --- a/avian/frontend/apt.js +++ b/avian/frontend/apt.js @@ -2367,6 +2367,7 @@ var adminSect = null; var ADMIN_TITLES = { settings: 'Settings', + frame: 'Frame', system: 'System', logs: 'Logs', tools: 'Tools', @@ -2402,6 +2403,7 @@ if (adminPollT) { clearInterval(adminPollT); adminPollT = null; } adminSect = section; if (section === 'settings') renderAdminSettings(); + else if (section === 'frame') renderAdminFrame(); else if (section === 'system') renderAdminSystem(); else if (section === 'logs') renderAdminLogs(); else if (section === 'tools') renderAdminTools(); @@ -2424,6 +2426,175 @@ return '
Pi unreachable - ' + adminEsc(reason || 'no data') + '
'; } + + // ---- Frame config admin panel ---- + function renderAdminFrame() { + adminBody.innerHTML = '

loading frame config...

'; + fetch('./avian/api/birdnet-api.php?action=frame_config', { credentials: 'same-origin', cache: 'no-store' }) + .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); }) + .then(function (fc) { + adminBody.innerHTML = + '
' + + '
Display
' + + frameSegmented('mode', 'Mode', 'how the time window works', fc.mode, [ + { v: 'today', label: 'today' }, + { v: 'window', label: 'window' }, + ]) + + frameSlider('hours', 'Window hours', 'rolling window size (window mode)', fc.hours, 1, 168, 1, 0) + + frameInput('start_time', 'Start time', 'daily reset time (today mode)', fc.start_time) + + frameToggle('hold_overnight', 'Hold overnight', 'keep old collage until first bird', fc.hold_overnight) + + frameSlider('quiet_start', 'Quiet start', 'hour to stop refreshing', fc.quiet_start, 0, 23, 1, 0) + + frameSlider('quiet_end', 'Quiet end', 'hour to resume refreshing', fc.quiet_end, 0, 23, 1, 0) + + frameSlider('heal_hours', 'Heal hours', 'force refresh even if unchanged', fc.heal_hours, 1, 48, 1, 0) + + '
Titles
' + + frameInput('shoot_title', 'Title', 'shown above the collage', fc.shoot_title || '') + + frameInput('shoot_subtitle', 'Subtitle', 'shown below the title', fc.shoot_subtitle || '') + + frameToggle('shoot_lowercase', 'Lowercase', 'lowercase the title text', fc.shoot_lowercase) + + '
Collage
' + + frameSlider('shoot_count_exp', 'Size exponent', 'how much detection count affects bird size', fc.shoot_count_exp, 0.3, 1.0, 0.05, 2) + + frameSlider('shoot_small_floor', 'Small floor', 'minimum size for rare birds', fc.shoot_small_floor, 0.01, 0.15, 0.01, 2) + + frameSlider('shoot_mat', 'Collage padding', 'padding around the collage area', fc.shoot_mat, 0, 0.1, 0.01, 2) + + frameSlider('shoot_headline_px', 'Headline size', 'title font size in px', fc.shoot_headline_px, 20, 80, 1, 0) + + frameSlider('shoot_eyebrow_px', 'Eyebrow size', 'subtitle font size in px', fc.shoot_eyebrow_px, 10, 40, 1, 0) + + '
Hardware
' + + frameSegmented('rotate', 'Rotation', 'orientation of the frame', fc.rotate, [ + { v: 90, label: '90' }, + { v: 270, label: '270' }, + ]) + + frameSlider('saturation', 'Saturation', 'color intensity for e-ink', fc.saturation, 0.0, 1.0, 0.05, 2) + + frameSlider('mat_opening_w', 'Mat width', 'matte opening width (inches, 0=default)', fc.mat_opening_w, 0, 12, 0.5, 1) + + frameSlider('mat_opening_h', 'Mat height', 'matte opening height (inches, 0=default)', fc.mat_opening_h, 0, 14, 0.5, 1) + + frameSlider('mat', 'Extra mat', 'global content shrink inside opening', fc.mat, 0, 0.1, 0.01, 2) + + frameInput('panel', 'Panel driver', 'force driver (blank=auto)', fc.panel || '') + + frameSlider('timeout', 'Timeout', 'seconds to wait for page render', fc.timeout, 10, 120, 5, 0) + + '
Source
' + + frameSegmented('species_source', 'Source', 'where bird data comes from', fc.species_source, [ + { v: '', label: 'local' }, + { v: 'birdweather', label: 'BirdWeather' }, + ]) + + frameInput('zip', 'ZIP code', 'for BirdWeather mode', fc.zip || '') + + frameSlider('bw_days', 'BW lookback', 'BirdWeather days to look back', fc.bw_days, 1, 30, 1, 0) + + frameInput('bw_country', 'BW country', 'geocoder country code', fc.bw_country || 'us') + + frameInput('image_url', 'Image URL', 'direct image URL (overrides shoot)', fc.image_url || '') + + frameToggle('shoot', 'Screenshot mode', 'capture collage via headless browser', fc.shoot) + + '
Auth
' + + frameInput('basic_user', 'Username', 'basic auth username', fc.basic_user || '') + + frameInput('basic_pass', 'Password', 'basic auth password', fc.basic_pass || '', true) + + '
'; + wireFrameControls(); + }) + .catch(function (err) { + adminBody.innerHTML = + ''; + }); + } + + function frameToggle(key, label, hint, on) { + return '' + + ''; + } + function frameSlider(key, label, hint, val, min, max, step, digits) { + return '' + + '
' + + '
' + + '
' + + ' ' + label + '' + + (hint ? '' + hint + '' : '') + + '
' + + ' ' + (+val).toFixed(digits) + '' + + '
' + + '
' + + ' ' + + '
' + + '
'; + } + function frameSegmented(key, label, hint, val, opts) { + var btns = opts.map(function (o) { + var match = String(o.v) === String(val); + return ''; + }).join(''); + return '' + + ''; + } + function frameInput(key, label, hint, val, isPass) { + return '' + + ''; + } + + var frameSaveTimer = null; + function saveFrameConfig(updates) { + clearTimeout(frameSaveTimer); + frameSaveTimer = setTimeout(function () { + fetch('./avian/api/birdnet-api.php?action=frame_config', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }).catch(function (e) { console.warn('frame config save failed', e); }); + }, 500); + } + + function wireFrameControls() { + var pending = {}; + adminBody.querySelectorAll('.switch[data-frame-key]').forEach(function (sw) { + sw.addEventListener('click', function () { + var on = sw.getAttribute('aria-checked') !== 'true'; + sw.setAttribute('aria-checked', on ? 'true' : 'false'); + pending[sw.dataset.frameKey] = on; + saveFrameConfig(pending); + }); + }); + adminBody.querySelectorAll('input[type="range"][data-frame-key]').forEach(function (sl) { + sl.addEventListener('input', function () { + var digits = +(sl.dataset.digits || 0); + var valEl = adminBody.querySelector('[data-frame-value="' + sl.dataset.frameKey + '"]'); + if (valEl) valEl.textContent = (+sl.value).toFixed(digits); + }); + sl.addEventListener('change', function () { + var v = +sl.value; + pending[sl.dataset.frameKey] = v; + saveFrameConfig(pending); + }); + }); + adminBody.querySelectorAll('.seg[data-frame-key]').forEach(function (seg) { + var key = seg.dataset.frameKey; + seg.querySelectorAll('button').forEach(function (btn) { + btn.addEventListener('click', function () { + seg.querySelectorAll('button').forEach(function (b) { b.setAttribute('aria-current', 'false'); }); + btn.setAttribute('aria-current', 'true'); + var v = btn.dataset.v; + if (v === 'true') v = true; + else if (v === 'false') v = false; + else if (!isNaN(+v) && v !== '') v = +v; + pending[key] = v; + saveFrameConfig(pending); + }); + }); + }); + adminBody.querySelectorAll('input[type="text"][data-frame-key], input[type="password"][data-frame-key]').forEach(function (inp) { + var key = inp.dataset.frameKey; + inp.addEventListener('change', function () { + pending[key] = inp.value; + saveFrameConfig(pending); + }); + }); + } + function renderAdminSettings() { adminBody.innerHTML = '

loading settings...

'; fetch('./avian/api/config.php', { credentials: 'same-origin', cache: 'no-store' }) diff --git a/avian/frontend/styles.css b/avian/frontend/styles.css index c2bb1c8..ceed7bd 100644 --- a/avian/frontend/styles.css +++ b/avian/frontend/styles.css @@ -1622,3 +1622,15 @@ * See avian/forwarding/. */ body.av-local #dd-locked { display: none; } body.av-local #dd-items { display: block; } + + .frame-section-label { + font: 600 11px/1 var(--sans, ui-sans-serif, system-ui, sans-serif); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ink-soft, #8a8078); + padding: 18px 0 6px; + border-bottom: 1px solid rgba(26,22,18,0.08); + margin-bottom: 2px; + } + .frame-section-label:first-child { padding-top: 4px; } +