$msg, 't' => $type]; } function take_flash(): array { $f = $_SESSION['flash'] ?? []; unset($_SESSION['flash']); return $f; } /* ---------- CSRF ---------- */ function csrf_token(): string { if (empty($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(32)); } return $_SESSION['csrf']; } function csrf_field(): string { return ''; } function csrf_check(): void { $ok = isset($_POST['csrf'], $_SESSION['csrf']) && hash_equals($_SESSION['csrf'], (string)$_POST['csrf']); if (!$ok) { http_response_code(419); exit('Session expired or invalid request. Please go back and try again.'); } } /* ---------- Settings (cached per request) ---------- */ function settings(): array { static $cache = null; if ($cache !== null) return $cache; $cache = []; foreach (db()->query('SELECT skey, svalue FROM settings') as $r) { $cache[$r['skey']] = $r['svalue']; } return $cache; } function setting(string $key, string $default = ''): string { $s = settings(); return $s[$key] ?? $default; } function set_setting(string $key, string $value): void { $st = db()->prepare( 'INSERT INTO settings (skey, svalue) VALUES (?, ?) ON DUPLICATE KEY UPDATE svalue = VALUES(svalue)'); $st->execute([$key, $value]); } /* ---------- Single-colour theme palette ---------- * One brand hex drives the whole UI. We derive tints/shades and pick a * readable text colour by luminance. Output as CSS custom properties. */ function hex_to_rgb(string $hex): array { $hex = ltrim($hex, '#'); if (strlen($hex) === 3) $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; if (!preg_match('/^[0-9a-fA-F]{6}$/', $hex)) $hex = '0d6e6e'; // safe fallback return [hexdec(substr($hex,0,2)), hexdec(substr($hex,2,2)), hexdec(substr($hex,4,2))]; } function rgb_to_hex(array $c): string { return sprintf('#%02x%02x%02x', max(0,min(255,(int)round($c[0]))), max(0,min(255,(int)round($c[1]))), max(0,min(255,(int)round($c[2])))); } /** mix toward white (pct>0) or black (pct<0); pct in -100..100 */ function shade(string $hex, int $pct): string { [$r,$g,$b] = hex_to_rgb($hex); $t = $pct >= 0 ? 255 : 0; $p = abs($pct) / 100; return rgb_to_hex([ $r + ($t - $r) * $p, $g + ($t - $g) * $p, $b + ($t - $b) * $p, ]); } /** white or near-black text for best contrast on a given colour */ function on_color(string $hex): string { [$r,$g,$b] = hex_to_rgb($hex); $lum = (0.2126*$r + 0.7152*$g + 0.0722*$b) / 255; return $lum > 0.6 ? '#10231f' : '#ffffff'; } function theme_css(): string { $brand = setting('brand_color', '#0d6e6e'); if (!preg_match('/^#?[0-9a-fA-F]{3,6}$/', $brand)) $brand = '#0d6e6e'; $vars = [ '--brand' => $brand, '--brand-700' => shade($brand, -22), '--brand-600' => shade($brand, -10), '--brand-300' => shade($brand, 45), '--brand-100' => shade($brand, 86), '--brand-50' => shade($brand, 94), '--on-brand' => on_color($brand), ]; $out = ':root{'; foreach ($vars as $k => $v) $out .= $k . ':' . $v . ';'; $out .= '}'; return $out; } /* ---------- Audit ---------- */ function audit(string $action, string $detail = '', ?int $uid = null): void { try { $st = db()->prepare('INSERT INTO audit_log (user_id, action, detail, ip) VALUES (?,?,?,?)'); $st->execute([$uid, $action, mb_substr($detail,0,255), $_SERVER['REMOTE_ADDR'] ?? '']); } catch (Throwable $e) { /* never block on logging */ } } /* ---------- Time helpers for schedules ---------- */ function fmt_time(?string $t): string { if (!$t) return '—'; return date('H:i', strtotime($t)); } function fmt_duration(?int $minutes): string { if ($minutes === null || $minutes < 0) return '—'; return intdiv($minutes,60) . 'h ' . str_pad((string)($minutes%60),2,'0',STR_PAD_LEFT) . 'm'; }