Ctrl K
ComunidadGuías…

templates/administration.html

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Administration — Lectio</title>
  <script>
    // Load the user's theme so CSS variables (--accent, --surface, …) are defined;
    // without it the page is unstyled and buttons render transparent.
    window.__lectioTheme = (window.localStorage.getItem('lectio-theme') || 'dark');
    document.write(`<link rel="stylesheet" href="/static/themes/${window.__lectioTheme}.css?v={{ static_asset_version }}" />`);
  </script>
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:FILL@0..1" />
  <link rel="stylesheet" href="/static/style.css?v={{ static_asset_version }}" />
  <style>
    body.account-page { height: auto; min-height: 100vh; overflow-y: auto; }
    .account-wrap { max-width: 760px; margin: 2rem auto; padding: 0 1rem; }
    .account-wrap header { display: flex; justify-content: space-between; align-items: baseline; }
    .account-section { margin: 1.5rem 0; padding: 1rem 1.25rem; border: 1px solid var(--line, #ccc); border-radius: 8px; }
    .account-section h2 { margin-top: 0; font-size: 1.05rem; }
    .account-section h3 { font-size: .95rem; margin: 1rem 0 .35rem; }
    .account-section form { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: .5rem 0; }
    .account-section label.fld { display: flex; flex-direction: column; gap: .2rem; font-size: .85rem; }
    .account-section input[type="text"], .account-section input[type="password"],
    .account-section input[type="number"], .account-section input[type="email"],
    .account-section select { padding: .4rem .5rem; }
    .account-msg { padding: .5rem .75rem; background: rgba(40,160,80,.15); border-radius: 6px; }
    .account-users { width: 100%; border-collapse: collapse; margin: .5rem 0; }
    .account-users th, .account-users td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid rgba(127,127,127,.25); }
    .account-hint { font-size: .82rem; opacity: .75; margin: .25rem 0; }
    /* Group descriptions sit between the heading and the field grid — give the
       fields a bit of air below the text. */
    h3 + .account-hint { margin-bottom: .65rem; }
    .account-users button.danger { color: var(--danger, #c0392b); border-color: var(--danger, #c0392b); }
    .account-users td > div form { margin: 0; }
    /* tabs */
    .adm-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--line, #ccc); margin: 1rem 0 0; }
    .adm-tab { padding: .45rem 1rem; cursor: pointer; border: 1px solid transparent;
                border-bottom: none; border-radius: 6px 6px 0 0; font-size: .9rem;
                background: none; color: inherit; }
    .adm-tab.active { border-color: var(--line, #ccc); background: var(--surface, #fff);
                       margin-bottom: -1px; font-weight: 600; }
    .adm-panel { display: none; }
    .adm-panel.active { display: block; }
    .tuning-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .5rem; }
    /* Inputs pin to the cell bottom so a label that wraps to two lines can't
       push its field out of alignment with the rest of the row. */
    .tuning-grid .fld input, .tuning-grid .fld select { margin-top: auto; }
    /* Credential pairs (ID + secret) are longer than tuning numbers — wider columns. */
    .keys-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
    .adm-icon-btn { background: none; border: none; padding: .1rem; cursor: pointer; color: inherit;
                    opacity: .65; vertical-align: middle; line-height: 1; }
    .adm-icon-btn:hover { opacity: 1; }
    .adm-icon-btn .material-symbols-rounded { font-size: 1.1rem; }
    .adm-icon-btn.danger { color: var(--danger, #c0392b); }
    .adm-user-actions { display: flex; gap: .15rem; align-items: center; }
    .adm-logs-controls { display: flex; gap: .75rem; align-items: flex-end; flex-wrap: wrap; margin-bottom: .5rem; }
    .adm-log-presets { display: flex; gap: .25rem; align-items: center; }
    .adm-log-preset { padding: .3rem .55rem; font-size: .8rem; cursor: pointer; }
    .adm-log-wrap-lbl { display: flex; align-items: center; gap: .3rem; font-size: .85rem; white-space: nowrap; }
    .adm-log-output { max-height: 62vh; overflow: auto; background: rgba(127,127,127,.08);
                      border: 1px solid var(--line, #ccc); border-radius: 6px; padding: .6rem .75rem;
                      font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .74rem;
                      line-height: 1.4; white-space: pre; margin: 0; }
    .adm-log-output.wrap { white-space: pre-wrap; word-break: break-word; }
    /* Per-level colorization. Continuation lines (tracebacks) inherit their
       record's class so a stack trace stays red under its ERROR. */
    .adm-log-output .lvl-debug    { color: var(--muted, #8a8a8a); }
    .adm-log-output .lvl-warning  { color: #c9931f; }
    .adm-log-output .lvl-error,
    .adm-log-output .lvl-critical { color: var(--danger, #d2544a); }
    .adm-log-output .log-ts { opacity: .55; }
    .adm-log-output .log-tag { color: var(--accent, #3a7abf); font-weight: 600; }
    /* Keep the tag readable when the whole line is an error/warning color. */
    .adm-log-output .lvl-error .log-tag,
    .adm-log-output .lvl-critical .log-tag { color: inherit; text-decoration: underline; }
  </style>
</head>
<body class="account-page">
  <div class="account-wrap">
    <header>
      <h1>Administration</h1>
      <a href="/">&larr; Back to Lectio</a>
    </header>

    {% if message %}<p class="account-msg" role="status">{{ message }}</p>{% endif %}
    {% if error %}<p class="login-error" role="alert">{{ error }}</p>{% endif %}
    <p id="admin-toast" class="account-msg" role="status" hidden></p>

    <div class="adm-tabs">
      <button class="adm-tab active" data-panel="panel-settings">Settings</button>
      <button class="adm-tab" data-panel="panel-keys">Shared keys</button>
      <button class="adm-tab" data-panel="panel-users">Users</button>
      <button class="adm-tab" data-panel="panel-logs">Logs</button>
    </div>

    <!-- ── Settings (instance behavior) ──────────────────────── -->
    <div id="panel-settings" class="adm-panel active">
      <section class="account-section">
        <h2>Instance settings</h2>
        <p class="account-hint">Shared across the whole instance. Changes apply immediately after Save.</p>

        <h3>Maintenance &amp; cleanup</h3>
        <p class="account-hint">Runs nightly at the set hour (server local time): prunes feed fetch history, applies per-folder retention, sweeps tombstones, and evicts the image cache.{% if maintenance_last %} Last ran: {{ maintenance_last }}.{% endif %}</p>
        <div class="tuning-grid">
          <label class="fld">Maintenance hour (0–23)
            <input type="number" id="adm-maint-hour" min="0" max="23" value="{{ maintenance_hour }}" placeholder="3" />
          </label>
          <label class="fld" title="How long feed-refresh attempts (success/error, per feed) are kept for diagnosing a feed that's gone quiet. A fixed 5000-row-per-feed ceiling backstops this against a runaway retry bug; you shouldn't need to think about it.">Feed history max age (days)
            <input type="number" id="adm-fetch-history-max-age" min="1" value="{{ fetch_history_max_age_days }}" placeholder="30" />
          </label>
          <label class="fld" title="New posts dated older than this are marked read when fetched, for every account. Accounts, folders and feeds can only shorten it. 0 = off.">Auto-read old posts (days, 0 = off)
            <input type="number" id="adm-auto-read-days" min="0" value="{{ auto_read_days_instance }}" placeholder="0" />
          </label>
          <label class="fld" title="Deleted-post tombstones stop refreshes from resurrecting purged entries. A tombstone is swept once it is this old AND its entry has left the publisher's last-known feed window (quiet feeds keep serving old posts, so age alone is never trusted). 0 keeps them forever.">Tomb sweeping (days, 0 = never)
            <input type="number" id="adm-tombstone-sweep" min="0" value="{{ tombstone_sweep_days }}" placeholder="90" />
          </label>
        </div>
        <div style="margin:.25rem 0 .75rem">
          <button type="button" id="adm-run-maint">Run maintenance now</button>
        </div>

        <h3>Image cache</h3>
        <p class="account-hint">The <code>/api/img</code> proxy caches fetched images. Entries not viewed within the retention window are evicted nightly; larger images are downscaled to the max dimension before storing.</p>
        <div class="tuning-grid">
          <label class="fld">Retention (days, 0 = keep forever)
            <input type="number" id="adm-img-cache-days" min="0" value="{{ img_cache_days }}" placeholder="90" />
          </label>
          <label class="fld">Max dimension (px, 0 = originals)
            <input type="number" id="adm-img-cache-max-dim" min="0" value="{{ img_cache_max_dim }}" placeholder="3840" />
          </label>
          {# Separate from max dimension because they answer different questions:
             a 4K PNG sits exactly at the dimension cap and still weighs ~12 MB,
             and it is the bytes that make it slow to load. #}
          <label class="fld">Size budget (bytes, 0 = as delivered)
            <input type="number" id="adm-img-target-bytes" min="0" value="{{ img_target_bytes }}" placeholder="1500000" />
          </label>
        </div>
        <p class="account-hint">An image over the size budget is re-encoded to WebP — losslessly for logos, diagrams and line art, where lossy compression would smear the edges, and lossy otherwise. The dimension cap alone does not bound size.</p>

        <h3>Outbound proxy</h3>
        <p class="account-hint">Routes feed fetches through a SOCKS5/HTTP proxy (e.g. a gluetun VPN container on the same Docker network) — useful against ordinary geoblocks or per-IP rate limits, not against sites running real bot detection. This is the instance default; a user can override it for themselves in Settings → Account. Off everywhere until set.</p>
        <div class="tuning-grid">
          <label class="fld">Proxy URL
            <input type="text" id="adm-proxy-url" value="{{ proxy_url }}" placeholder="socks5h://gluetun:1080" autocomplete="off" />
          </label>
          <label class="fld">Default mode
            <select id="adm-proxy-mode">
              <option value="off" {{ 'selected' if proxy_mode == 'off' }}>Off</option>
              <option value="as_needed" {{ 'selected' if proxy_mode == 'as_needed' }}>As needed</option>
              <option value="always" {{ 'selected' if proxy_mode == 'always' }}>Always</option>
            </select>
          </label>
        </div>
        <p class="account-hint">FlareSolverr (real headless Chrome, purpose-built to solve Cloudflare/DDoS-Guard style challenges) — one rung past the proxy above, before the last-resort backend below. Only reached in "As needed" mode for a feed still failing with an actual challenge page (not a plain block or rate limit), and always stacked with the proxy above when one is configured. Leave blank to opt out — no token needed for an internal-network container.</p>
        <div class="tuning-grid">
          <label class="fld">FlareSolverr URL
            <input type="text" id="adm-flaresolverr-url" value="{{ flaresolverr_url }}" placeholder="http://flaresolverr:8191/v1" autocomplete="off" />
          </label>
        </div>
        <p class="account-hint">Last-resort backend (e.g. a Tailscale exit node reaching a real home IP) — a different trust tier from the proxy above: real exposure cost, not a disposable datacenter address. Only reached in "As needed" mode, the last escalation rung (browser identity, then proxy, then FlareSolverr, then this), never in "Always". Leave blank to opt out entirely — an unconfigured URL is the gate. Also honors the "As needed" mode override in Settings → Account.</p>
        <div class="tuning-grid">
          <label class="fld">Last-resort proxy URL
            <input type="text" id="adm-tailscale-url" value="{{ tailscale_url }}" placeholder="socks5h://tailscale:1080" autocomplete="off" />
          </label>
        </div>

        <h3>Security</h3>
        <p class="account-hint">Repeated failed logins lock the account out for the window.</p>
        <div class="tuning-grid">
          <label class="fld">Login max failures
            <input type="number" id="adm-login-max-failures" min="1" value="{{ login_max_failures }}" placeholder="5" />
          </label>
          <label class="fld">Lockout window (seconds)
            <input type="number" id="adm-login-window-seconds" min="60" value="{{ login_window_seconds }}" placeholder="300" />
          </label>
        </div>

        <h3>New users</h3>
        <p class="account-hint">Seeds the auto-refresh setting for newly created users.</p>
        <div class="tuning-grid">
          <label class="fld">Default auto-refresh (min, 0 = off)
            <input type="number" id="adm-instance-auto-refresh" min="0" value="{{ instance_auto_refresh }}" placeholder="60" />
          </label>
        </div>

        <div style="margin-top:.75rem">
          <button type="button" class="adm-save">Save configuration</button>
        </div>
      </section>
    </div>

    <!-- ── Shared keys (instance-wide credentials) ───────────── -->
    <div id="panel-keys" class="adm-panel">
      <section class="account-section">
        <h2>Shared keys</h2>
        <p class="account-hint">Instance-wide credentials. The email key sends on behalf of the whole instance; the OAuth apps are a shared fallback for users who haven't set their own (a user's own credentials always take priority).</p>

        <h3>Email (Resend)</h3>
        <p class="account-hint">One sending account for the instance (only a verified domain can send).</p>
        <div class="tuning-grid keys-grid">
          <label class="fld">API key
            <input type="password" id="adm-resend-key" autocomplete="new-password"
                   placeholder="{{ resend_key_masked if resend_key_set else 're_…' }}" />
          </label>
          <label class="fld">From address
            <input type="email" id="adm-email-from" value="{{ email_from }}" placeholder="lectio@yourdomain.com" />
          </label>
        </div>

        <h3>YouTube OAuth</h3>
        <p class="account-hint">Register at the <a href="https://console.cloud.google.com/" target="_blank" rel="noopener">Google Cloud Console</a> with <code>{{ public_url }}/youtube/oauth/callback</code> as an authorized redirect URI.</p>
        <div class="tuning-grid keys-grid">
          <label class="fld">Client ID
            <input type="text" id="adm-yt-oauth-client-id" value="{{ shared_yt_oauth_client_id }}" placeholder="166660….apps.googleusercontent.com" autocomplete="off" />
          </label>
          <label class="fld">Client secret
            <input type="password" id="adm-yt-oauth-client-secret" autocomplete="new-password"
                   placeholder="{{ shared_yt_oauth_client_secret_masked if shared_yt_oauth_client_secret_set else 'GOCSPX-…' }}" />
          </label>
        </div>

        <h3>Pinterest OAuth</h3>
        <p class="account-hint">Register at <a href="https://developers.pinterest.com/apps/" target="_blank" rel="noopener">Pinterest Developers</a> with <code>{{ public_url }}/integrations/pinterest/oauth/callback</code> as a redirect URI.</p>
        <div class="tuning-grid keys-grid">
          <label class="fld">Client ID
            <input type="text" id="adm-pinterest-oauth-client-id" value="{{ shared_pinterest_oauth_client_id }}" placeholder="your app ID" autocomplete="off" />
          </label>
          <label class="fld">Client secret
            <input type="password" id="adm-pinterest-oauth-client-secret" autocomplete="new-password"
                   placeholder="{{ shared_pinterest_oauth_client_secret_masked if shared_pinterest_oauth_client_secret_set else 'your client secret' }}" />
          </label>
        </div>

        <h3>Reddit OAuth</h3>
        <p class="account-hint">Register a <em>web app</em> at <a href="https://www.reddit.com/prefs/apps" target="_blank" rel="noopener">reddit.com/prefs/apps</a> with <code>{{ public_url }}/integrations/reddit/oauth/callback</code> as the redirect URI.</p>
        <div class="tuning-grid keys-grid">
          <label class="fld">Client ID
            <input type="text" id="adm-reddit-client-id" value="{{ shared_reddit_client_id }}" placeholder="your app client ID" autocomplete="off" />
          </label>
          <label class="fld">Client secret
            <input type="password" id="adm-reddit-client-secret" autocomplete="new-password"
                   placeholder="{{ shared_reddit_client_secret_masked if shared_reddit_client_secret_set else 'your client secret' }}" />
          </label>
        </div>

        <div style="margin-top:.75rem">
          <button type="button" class="adm-save">Save configuration</button>
        </div>
      </section>
    </div>

    <!-- ── Users ─────────────────────────────────────────────── -->
    <div id="panel-users" class="adm-panel">

      <section class="account-section">
        <h2>Users</h2>
        <table class="account-users">
          <thead><tr><th>User</th><th>Admin</th><th>Last active</th><th>Feeds</th><th>DB size</th><th>Actions</th></tr></thead>
          <tbody>
          {% for u in users %}
            <tr>
              <td>{{ u.username }}{% if u.disabled %} <em>(disabled)</em>{% endif %}
                <button type="button" class="adm-icon-btn" data-user-act="rename" data-user-id="{{ u.user_id }}" data-username="{{ u.username }}" title="Rename (login name only — data and tokens unaffected)" aria-label="Rename {{ u.username }}"><span class="material-symbols-rounded" aria-hidden="true">edit</span></button>
              </td>
              <td>{{ 'yes' if u.is_admin else '' }}</td>
              <td>{{ u.last_active }}</td>
              <td>{{ u.feed_count }}</td>
              <td>{{ u.db_human }}</td>
              <td>
                <div class="adm-user-actions">
                  <button type="button" class="adm-icon-btn" data-user-act="vacuum" data-user-id="{{ u.user_id }}" data-username="{{ u.username }}" title="Vacuum DBs (reclaim space after purges; runs in background)" aria-label="Vacuum {{ u.username }}'s databases"><span class="material-symbols-rounded" aria-hidden="true">cleaning_services</span></button>
                  {% if u.user_id != user_id %}
                  <button type="button" class="adm-icon-btn" data-user-act="resetpw" data-user-id="{{ u.user_id }}" data-username="{{ u.username }}" title="Reset password" aria-label="Reset {{ u.username }}'s password"><span class="material-symbols-rounded" aria-hidden="true">key</span></button>
                  <button type="button" class="adm-icon-btn" data-user-act="disable" data-user-id="{{ u.user_id }}" data-username="{{ u.username }}" data-disabled="{{ 1 if u.disabled else 0 }}" title="{{ 'Enable' if u.disabled else 'Disable' }}" aria-label="{{ 'Enable' if u.disabled else 'Disable' }} {{ u.username }}"><span class="material-symbols-rounded" aria-hidden="true">{{ 'person' if u.disabled else 'person_off' }}</span></button>
                  <button type="button" class="adm-icon-btn danger" data-user-act="delete" data-user-id="{{ u.user_id }}" data-username="{{ u.username }}" title="Delete user and ALL their data" aria-label="Delete {{ u.username }}"><span class="material-symbols-rounded" aria-hidden="true">delete</span></button>
                  {% endif %}
                </div>
              </td>
            </tr>
          {% endfor %}
          </tbody>
        </table>

        <h3>Create user</h3>
        <form method="post" action="/admin/users/create">
          {{ csrf_input(request) | safe }}
          <input type="text" name="username" placeholder="username" minlength="4" maxlength="10" required />
          <input type="password" name="password" placeholder="password" autocomplete="new-password" minlength="6" maxlength="36" required />
          <label><input type="checkbox" name="is_admin" value="1" /> admin</label>
          <button type="submit">Create user</button>
        </form>
        <p class="account-hint">Password changes and API tokens for your own account live in Lectio's Settings → Account.</p>
      </section>
    </div>

    <!-- ── Logs ──────────────────────────────────────────────── -->
    <div id="panel-logs" class="adm-panel">
      <section class="account-section">
        <h2>Logs</h2>
        <div class="adm-logs-controls">
          <label class="fld">Since
            <input type="datetime-local" id="adm-log-since" step="60" />
          </label>
          <label class="fld">Until
            <input type="datetime-local" id="adm-log-until" step="60" title="Leave blank for now" />
          </label>
          <label class="fld">Level
            <select id="adm-log-level">
              <option value="all" selected>All</option>
              <option value="info">Info+</option>
              <option value="warning">Warning+</option>
              <option value="error">Error only</option>
            </select>
          </label>
          <label class="fld">Category
            <select id="adm-log-category">
              <option value="all" selected>All</option>
              <option value="automations">Automations</option>
              <option value="maintenance">Maintenance</option>
              <option value="refresh">Refresh &amp; fetch</option>
              <option value="youtube">YouTube</option>
              <option value="integrations">Integrations</option>
              <option value="images">Images</option>
              <option value="other">Other / untagged</option>
            </select>
          </label>
          <div class="adm-log-presets">
            <button type="button" class="adm-log-preset" data-mins="15">15m</button>
            <button type="button" class="adm-log-preset" data-mins="60">1h</button>
            <button type="button" class="adm-log-preset" data-mins="360">6h</button>
            <button type="button" class="adm-log-preset" data-mins="1440">1d</button>
          </div>
          <label class="adm-log-wrap-lbl"><input type="checkbox" id="adm-log-wrap" /> Wrap</label>
          <button type="button" id="adm-log-refresh">↻ Refresh</button>
        </div>
        <p id="adm-log-status" class="account-hint"></p>
        <pre id="adm-log-output" class="adm-log-output" aria-live="polite"></pre>
      </section>
    </div>
  </div>

  <script>
    (function () {
      const CSRF = "{{ csrf_token_for(request) }}";
      const $ = id => document.getElementById(id);
      const toast = (msg) => { const t = $('admin-toast'); t.textContent = msg; t.hidden = false; };

      // Tab switching
      document.querySelectorAll('.adm-tab').forEach(btn => {
        btn.addEventListener('click', () => {
          document.querySelectorAll('.adm-tab').forEach(b => b.classList.remove('active'));
          document.querySelectorAll('.adm-panel').forEach(p => p.classList.remove('active'));
          btn.classList.add('active');
          document.getElementById(btn.dataset.panel).classList.add('active');
        });
      });

      async function post(url, payload) {
        const r = await fetch(url, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
          credentials: 'same-origin',
          body: JSON.stringify(payload || {}),
        });
        if (!r.ok) throw new Error('failed');
      }

      async function saveConfig() {
        const payload = {
          email_from: $('adm-email-from')?.value.trim() || '',
          maintenance_hour: $('adm-maint-hour')?.value.trim() || '',
          img_cache_days: $('adm-img-cache-days')?.value.trim() || '',
          img_cache_max_dim: $('adm-img-cache-max-dim')?.value.trim() || '',
          img_target_bytes: $('adm-img-target-bytes')?.value.trim() || '',
          proxy_url: $('adm-proxy-url')?.value.trim() || '',
          proxy_mode: $('adm-proxy-mode')?.value || '',
          tailscale_url: $('adm-tailscale-url')?.value.trim() || '',
          flaresolverr_url: $('adm-flaresolverr-url')?.value.trim() || '',
          shared_yt_oauth_client_id: $('adm-yt-oauth-client-id')?.value.trim() || '',
          shared_pinterest_oauth_client_id: $('adm-pinterest-oauth-client-id')?.value.trim() || '',
          fetch_history_max_age_days: $('adm-fetch-history-max-age')?.value.trim() || '',
          tombstone_sweep_days: $('adm-tombstone-sweep')?.value.trim() || '',
          auto_read_days_instance: $('adm-auto-read-days')?.value.trim() || '',
          login_max_failures: $('adm-login-max-failures')?.value.trim() || '',
          login_window_seconds: $('adm-login-window-seconds')?.value.trim() || '',
          default_auto_refresh_minutes: $('adm-instance-auto-refresh')?.value.trim() || '',
        };
        const rk = $('adm-resend-key')?.value.trim();
        if (rk) payload.resend_api_key = rk;
        const ytSecret = $('adm-yt-oauth-client-secret')?.value.trim();
        if (ytSecret) payload.shared_yt_oauth_client_secret = ytSecret;
        const piSecret = $('adm-pinterest-oauth-client-secret')?.value.trim();
        if (piSecret) payload.shared_pinterest_oauth_client_secret = piSecret;
        const rdId = $('adm-reddit-client-id')?.value.trim();
        if (rdId !== undefined) payload.shared_reddit_client_id = rdId;
        const rdSecret = $('adm-reddit-client-secret')?.value.trim();
        if (rdSecret) payload.shared_reddit_client_secret = rdSecret;
        try { await post('/settings/all', payload); toast('Configuration saved.'); }
        catch { toast('Failed to save.'); }
      }
      document.querySelectorAll('.adm-save').forEach(btn => btn.addEventListener('click', saveConfig));

      $('adm-run-maint')?.addEventListener('click', async () => {
        try { await post('/settings/maintenance/run-now'); toast('Maintenance started.'); }
        catch { toast('Failed to start maintenance.'); }
      });

      // ── Users table glyph actions ─────────────────────────────
      async function postForm(url, fields) {
        const r = await fetch(url, {
          method: 'POST',
          headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': CSRF },
          credentials: 'same-origin',
          body: new URLSearchParams(fields).toString(),
        });
        if (!r.ok) throw new Error('failed');
        return r;
      }

      document.addEventListener('click', async (ev) => {
        const btn = ev.target.closest('[data-user-act]');
        if (!btn) return;
        const act = btn.dataset.userAct;
        const userId = btn.dataset.userId;
        const username = btn.dataset.username;
        try {
          if (act === 'rename') {
            const name = prompt(`New login name for ${username} (data and tokens are unaffected):`, username);
            if (!name || name === username) return;
            await postForm('/admin/users/rename', { user_id: userId, new_username: name.trim() });
            location.reload();
          } else if (act === 'resetpw') {
            const pw = prompt(`New password for ${username} (min 6 characters):`);
            if (!pw) return;
            if (pw.length < 6) { toast('Password too short.'); return; }
            await postForm('/admin/users/reset-password', { user_id: userId, new_password: pw });
            toast(`Password reset for ${username}.`);
          } else if (act === 'disable') {
            const disabling = btn.dataset.disabled !== '1';
            if (disabling && !confirm(`Disable ${username}? They will be signed out and unable to log in.`)) return;
            await postForm('/admin/users/disable', { user_id: userId, disabled: disabling ? '1' : '0' });
            location.reload();
          } else if (act === 'delete') {
            if (!confirm(`Permanently delete ${username} and ALL their feeds, folders, and saved articles? This cannot be undone.`)) return;
            if (prompt(`Type the username to confirm deletion:`) !== username) { toast('Deletion cancelled.'); return; }
            await postForm('/admin/users/delete', { user_id: userId });
            location.reload();
          } else if (act === 'vacuum') {
            const data = await (await postForm('/admin/users/vacuum', { user_id: userId })).json();
            toast(data.mode === 'full'
              ? `Full vacuum started for ${username} — DB sizes update after it finishes (check Logs).`
              : `Light vacuum started for ${username} (full skipped: ${(data.reasons || []).join('; ')}). Try the full one when they're idle.`);
          }
        } catch {
          toast(`Action failed for ${username}.`);
        }
      });

      // ── Logs tab ──────────────────────────────────────────────
      const LOG_LINE_RE = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[,.]\d+) (DEBUG|INFO|WARNING|ERROR|CRITICAL) /;
      const esc = (s) => s.replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));

      // Format a Date as the value a datetime-local input expects (local time).
      function toLocalInput(d) {
        const p = n => String(n).padStart(2, '0');
        return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
      }

      // Highlight bracketed subsystem tags like [maintenance] / [refresh] /
      // [deviantart] so they stand out. Runs on already-escaped text.
      const tagify = (escaped) => escaped.replace(/\[([a-z][a-z0-9 _-]*)\]/gi, '<span class="log-tag">[$1]</span>');

      function renderLogLines(lines) {
        // Colorize by level; continuation lines (tracebacks) inherit the class
        // of the record they belong to.
        let currentClass = '';
        return lines.map(line => {
          const m = LOG_LINE_RE.exec(line);
          if (m) {
            const lvl = m[2].toLowerCase();
            currentClass = (lvl === 'debug' || lvl === 'warning' || lvl === 'error' || lvl === 'critical')
              ? `lvl-${lvl}` : '';
            const rest = tagify(esc(line.slice(m[0].length)));
            return `<span class="${currentClass}"><span class="log-ts">${esc(m[1])}</span> ${esc(m[2])} ${rest}</span>`;
          }
          return `<span class="${currentClass}">${esc(line)}</span>`;
        }).join('\n');
      }

      let _logsSeq = 0;
      async function loadLogs() {
        const seq = ++_logsSeq;
        const out = $('adm-log-output');
        const status = $('adm-log-status');
        const level = $('adm-log-level')?.value || 'all';
        const since = $('adm-log-since')?.value || '';
        const until = $('adm-log-until')?.value || '';
        const category = $('adm-log-category')?.value || 'all';
        if (status) status.textContent = 'Loading…';
        try {
          const params = new URLSearchParams({ level, category });
          if (since) params.set('since', since);
          if (until) params.set('until', until);
          const r = await fetch(`/admin/logs?${params.toString()}`, { credentials: 'same-origin' });
          const d = await r.json();
          if (seq !== _logsSeq) return;  // a newer filter change superseded this request
          if (!d.available) {
            out.innerHTML = '';
            status.textContent = 'File logging is not enabled (set LECTIO_LOG_DIR).';
            return;
          }
          out.innerHTML = renderLogLines(d.lines || []);
          const range = since && until ? `${since.replace('T', ' ')} → ${until.replace('T', ' ')}`
            : since ? `since ${since.replace('T', ' ')}`
            : until ? `until ${until.replace('T', ' ')}`
            : 'most recent';
          const cap = d.truncated ? ' — capped; narrow the range to see older lines' : '';
          status.textContent = `${d.count} line${d.count === 1 ? '' : 's'} (${range})${cap}.`;
          out.scrollTop = out.scrollHeight;
        } catch {
          if (seq === _logsSeq && status) status.textContent = 'Failed to load logs.';
        }
      }

      // Presets set "Since" to N minutes ago, clear "Until" (= now), and reload.
      document.querySelectorAll('.adm-log-preset').forEach(btn => {
        btn.addEventListener('click', () => {
          const mins = parseInt(btn.dataset.mins, 10) || 60;
          const sinceInput = $('adm-log-since');
          if (sinceInput) sinceInput.value = toLocalInput(new Date(Date.now() - mins * 60000));
          if ($('adm-log-until')) $('adm-log-until').value = '';
          loadLogs();
        });
      });
      $('adm-log-refresh')?.addEventListener('click', loadLogs);
      $('adm-log-level')?.addEventListener('change', loadLogs);
      $('adm-log-category')?.addEventListener('change', loadLogs);
      $('adm-log-since')?.addEventListener('change', loadLogs);
      $('adm-log-until')?.addEventListener('change', loadLogs);
      $('adm-log-wrap')?.addEventListener('change', (e) => {
        $('adm-log-output')?.classList.toggle('wrap', e.target.checked);
      });
      // Lazy-load logs the first time the Logs tab is opened; default to the
      // last 15 minutes so the view isn't the whole file.
      let _logsLoaded = false;
      document.querySelector('.adm-tab[data-panel="panel-logs"]')?.addEventListener('click', () => {
        if (!_logsLoaded) {
          _logsLoaded = true;
          const sinceInput = $('adm-log-since');
          if (sinceInput && !sinceInput.value) sinceInput.value = toLocalInput(new Date(Date.now() - 15 * 60000));
          loadLogs();
        }
      });
    })();
  </script>
</body>
</html>