<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Zimi</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="apple-touch-icon-precomposed" sizes="180x180" href="/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Zimi">
<link rel="shortcut icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADo0lEQVR4nM1XS4scVRT+zrm3qh+ZyQMaRxMQdBgNRM1iCAhqjIuIe/GBuIpxEcV/4MJNAmYniAuZ7LJL1IW4EomoqEEHt0oYxTct4xhH01Ndj3vk3Krq1Ey6x+r0JJMD1Y9Tt873ne/ec+4tYJuNNrlnivsOgEwQn4vns7oEqAKcO2gznqNNZB3vksg6Jw0B9wNmZmaORFH8lHPZfoACovFUENFYkjCbb5vN8Fy32/14I8ZGAj7z+fl5s7T0w1tpmhwXcRpoIlPxiBjWBguzs3e9tLi4mFWVoA0Sud279ywkSfKCiJQDr0//q+ZjEJEJguDM5ct/HS+xqgR0wWWdzh2PRNG/nzjn0soi3Arzi5CZbbM5dXh5+fdPS0wuBnigJFl73jlXik7D5fyfazgB79bYilH12eKPl0MEc5UquMbizI8ZKYsyZwIsjyRBBcYA05bP5qUmKstIu32KYIahF5ppCCW5EvkSGEXTKJYUNWo3Ayxp66RYA7z3TIC5PYR+QuByHUv+O3NA0xK++s3hiXciBFyve1mMYZ0mYWdb0yyoFT0yjgFxhEYItA2QCRBiiwhIIa1zwLPvJmjZXBGSfL6vxILXDzfw0D5CEhNOXkwgTkBW+9AWEChNY335ixsENZppX/D0fRYP7mVYQzh1McH7l1JMt8irgK2egqmwAGegFwP372MsPN5AwzIufJ/htS9i7GiSV6iu8TgENLCu3SQDQgbePtrAdIPQ/Vvw4kfRYLrG6d6MMc1nHwlOPxri0F4DlzJeudDH0opDO8hJ4kYRsAysrgmeO2Bx4qCiMd78Jsa57xLsbBHSwQZ+AwgYyuf93g7jjccaIGZ8/avDq5/3Md2kwZihjWrSRUiAz07lP3O0gU7b4OcVwZMf9PDPmgOCoicUg1sB1d7FbC0CBPQTwWlf79bX+4c/prhnl8Gh2yySLAfUz9gJPusmSDLxz01MgAiIUuDOXYyXHwiBlMEZ4dj+Jo4daANOs+f8mwx6PcHc+S6WM0FQoxvaOgp4IgB+WgV2aAPyLVjgMj0xadN3vhVrsD8jGasSbBk/353ompOrekMD/NETPHz+yrq5rR6Xqke3XioY3YkpKzZCv/3awuuPSES4BODIMOXUsRrXS21EJeR7Z44xwOTKTQRB6yyzbjFXfVXTrOpcI8ChsRWj6qPtPpTyhoE8O3v3iSAIF5jZUL6uzCSXxtBYGlNjV15Qbr0Xk1vi1QwVuykvp9tu/wHSK47GCFrSNAAAAABJRU5ErkJggg==">
<style>
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

  :root {
    --bg: #0a0a0b; --surface: #141416; --surface2: #1c1c20;
    --border: #27272b; --text: #e8e8ed; --text2: #6e6e7a;
    --accent: #f5f5f7; --amber: #f59e0b; --amber2: #d97706;
    --amber-glow: rgba(245, 158, 11, 0.08); --amber-border: rgba(245, 158, 11, 0.25);
    --radius: 10px;
    font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
  }

  html { height: 100%; overflow: hidden; }
  body { background: var(--bg); color: var(--text); height: 100%; padding-top: 56px; overflow-y: auto; overscroll-behavior-y: none; }

  /* ── Topbar ── */
  .topbar {
    position: fixed; top: 0; left: 0; right: 0; z-index: 200;
    height: 56px; display: flex; align-items: center; gap: 10px;
    padding: 0 16px; background: rgba(10, 10, 11, 0.85); backdrop-filter: blur(20px) saturate(180%);
    -webkit-backdrop-filter: blur(20px) saturate(180%);
    border-bottom: 1px solid var(--border);
  }
  .topbar .logo {
    font-size: 18px; font-weight: 700; letter-spacing: -0.5px;
    text-decoration: none; white-space: nowrap; flex-shrink: 0; cursor: pointer;
    background: linear-gradient(135deg, #f59e0b, #f97316, #ef4444);
    -webkit-background-clip: text; -webkit-text-fill-color: transparent;
    background-clip: text; transition: opacity 0.15s;
  }
  .topbar .logo:hover { opacity: 0.8; }

  .back-btn {
    display: none; align-items: center; justify-content: center;
    background: none; border: 1px solid transparent; border-radius: var(--radius);
    color: var(--text2); font-size: 16px; padding: 4px 6px; cursor: pointer;
    flex-shrink: 0; transition: all 0.15s; line-height: 1;
  }
  .back-btn:hover { color: var(--amber); border-color: var(--amber-border); background: var(--amber-glow); }

  /* ── History trail dropdown ── */
  #history-trail {
    position: fixed; z-index: 9999; min-width: 220px; max-width: 360px;
    max-height: 400px; overflow-y: auto;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
    padding: 4px 0; display: none;
  }
  #history-trail.visible { display: block; }
  .history-item {
    display: flex; align-items: center; gap: 8px;
    padding: 8px 14px; cursor: pointer; font-size: 13px;
    color: var(--text); transition: background 0.1s;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .history-item:hover, .history-item.hover { background: var(--amber-glow); color: var(--amber); }
  #history-trail, .back-btn { user-select: none; -webkit-user-select: none; }
  .history-item .hi-source {
    flex-shrink: 0; font-size: 10px; font-weight: 600;
    color: var(--text2); background: var(--surface2);
    border-radius: 3px; padding: 1px 5px; text-transform: uppercase;
  }
  .history-item .hi-title { overflow: hidden; text-overflow: ellipsis; }
  .history-empty { padding: 12px 14px; font-size: 13px; color: var(--text2); text-align: center; }

  .topbar-breadcrumb { display: flex; align-items: center; gap: 8px; flex-shrink: 0; min-width: 0; }
  .bc-sep { color: var(--text2); font-size: 18px; opacity: 0.5; font-weight: 300; }
  .bc-icon { display: none; flex-shrink: 0; line-height: 0; cursor: pointer; }
  .bc-icon img { width: 18px; height: 18px; border-radius: 4px; object-fit: cover; background: #fff; padding: 1px; }
  .bc-icon .bc-letter {
    display: inline-flex; align-items: center; justify-content: center;
    width: 22px; height: 22px; border-radius: 5px;
    background: var(--surface2); color: var(--accent);
    font-size: 12px; font-weight: 700;
  }

  .topbar-search { flex: 1; position: relative; margin-left: 4px; }
  .topbar-search input {
    width: 100%; padding: 9px 14px 9px 36px;
    background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
    color: var(--text); font-size: 16px; outline: none;
    transition: border-color 0.2s, box-shadow 0.2s;
  }
  .topbar-search input:focus {
    border-color: var(--amber-border);
    box-shadow: 0 0 0 3px var(--amber-glow), 0 0 20px var(--amber-glow);
  }
  .topbar-search input::placeholder { color: var(--text2); }
  .topbar-search .search-icon {
    position: absolute; left: 11px; top: 50%; transform: translateY(-50%);
    width: 14px; height: 14px; color: var(--text2); pointer-events: none;
  }
  .topbar-search .kbd {
    position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
    font-size: 11px; color: var(--text2); background: var(--surface2);
    border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px;
    font-family: inherit; pointer-events: none; opacity: 0.7; transition: opacity 0.15s;
  }
  .topbar-search input:focus ~ .kbd { opacity: 0; }

  /* ── Suggest dropdown ── */
  .suggest-dropdown {
    position: absolute; top: 100%; left: 0; right: 0;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: 0 0 var(--radius) var(--radius);
    display: none; z-index: 300; max-height: 300px; overflow-y: auto;
    box-shadow: 0 8px 32px rgba(0,0,0,0.4);
  }
  .suggest-item {
    padding: 8px 14px; cursor: pointer; font-size: 13px; transition: background 0.1s;
  }
  .suggest-item:hover, .suggest-item.active { background: var(--amber-glow); }
  .suggest-item .sg-title { color: var(--text); }
  .suggest-item .sg-source { font-size: 11px; color: var(--text2); margin-top: 1px; }

  .random-btn, .manage-btn, .newtab-btn {
    display: flex; align-items: center; justify-content: center;
    color: var(--text2); text-decoration: none;
    flex-shrink: 0; padding: 6px 10px;
    border-radius: var(--radius); transition: all 0.2s; border: 1px solid transparent;
  }
  .random-btn { font-size: 18px; }
  .manage-btn, .newtab-btn { display: none; font-size: 16px; }
  .random-btn:hover, .manage-btn:hover, .newtab-btn:hover { color: var(--amber); border-color: var(--amber-border); background: var(--amber-glow); }
  .random-btn .dice { display: inline-block; transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
  .random-btn:hover .dice { transform: rotate(20deg) scale(1.15); }
  .random-btn.rolling .dice { animation: diceRoll 0.5s cubic-bezier(0.34, 1.56, 0.64, 1); }
  @keyframes diceRoll {
    0% { transform: rotate(0deg) scale(1); }
    30% { transform: rotate(180deg) scale(1.3); }
    60% { transform: rotate(360deg) scale(0.9); }
    100% { transform: rotate(360deg) scale(1); }
  }

  /* ── Layout ── */
  .container { max-width: 820px; margin: 0 auto; padding: 28px 16px; }

  /* ── Stats bar ── */
  .stats-bar { text-align: center; padding: 8px 0 20px; font-size: 14px; color: var(--text2); }
  .stats-bar .num { color: var(--amber); font-weight: 600; }

  /* ── Pills (categories + search source filters) ── */
  .pills { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-bottom: 20px; }
  .pill {
    padding: 6px 14px; font-size: 12px; font-weight: 500;
    background: var(--surface); border: 1px solid var(--border); border-radius: 20px;
    color: var(--text2); cursor: pointer; transition: all 0.2s;
  }
  .pill:hover { border-color: var(--amber-border); color: var(--text); background: var(--amber-glow); }
  .pill.active {
    background: linear-gradient(135deg, var(--amber), var(--amber2));
    color: #000; border-color: transparent; font-weight: 600;
  }

  /* ── Category headings ── */
  .cat-heading {
    font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px;
    margin: 32px 0 10px; padding-left: 2px;
    background: linear-gradient(90deg, var(--amber), var(--text2));
    -webkit-background-clip: text; -webkit-text-fill-color: transparent;
    background-clip: text;
  }
  .cat-heading:first-child { margin-top: 0; }
  .cat-heading.clickable { cursor: pointer; }
  .cat-heading.clickable:hover { opacity: 0.8; }

  /* ── Source header ── */
  .source-header {
    display: flex; gap: 16px; align-items: flex-start; padding: 4px 0 24px;
  }
  .sh-icon {
    width: 64px; height: 64px; flex-shrink: 0; border-radius: 12px; overflow: hidden;
    background: var(--surface2); display: flex; align-items: center; justify-content: center;
  }
  .sh-icon img { width: 58px; height: 58px; object-fit: cover; background: #fff; border-radius: 10px; padding: 2px; }
  .sh-info { flex: 1; min-width: 0; }
  .source-header h1 { font-size: 20px; font-weight: 600; color: var(--accent); margin: 0; }
  .sh-meta { font-size: 13px; color: var(--text2); margin-top: 4px; }
  .sh-desc { font-size: 13px; color: var(--text2); margin-top: 4px; line-height: 1.5; }

  /* ── Source cards ── */
  .stats-grid {
    display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
    gap: 8px; margin-top: 8px;
  }
  .stat-card {
    background: var(--surface); border-radius: var(--radius); padding: 14px;
    border: 1px solid var(--border);
    transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
    display: flex; gap: 14px; align-items: stretch; text-decoration: none; color: inherit; cursor: pointer;
  }
  .stat-card:hover, .stat-card:focus-visible {
    background: var(--surface2); border-color: var(--amber-border);
    box-shadow: 0 4px 24px var(--amber-glow), 0 0 0 1px var(--amber-glow);
    transform: translateY(-2px); outline: none;
  }
  .card-icon {
    width: 48px; height: 48px; flex-shrink: 0; border-radius: 10px; overflow: hidden;
    background: var(--surface2); display: flex; align-items: center; justify-content: center;
    align-self: center;
  }
  .card-icon img { width: 42px; height: 42px; object-fit: cover; background: #fff; border-radius: 8px; padding: 2px; }
  .icon-letter {
    font-size: 20px; font-weight: 700;
    background: linear-gradient(135deg, var(--amber), #f97316);
    -webkit-background-clip: text; -webkit-text-fill-color: transparent;
    background-clip: text;
  }
  .card-info { flex: 1; min-width: 0; display: flex; flex-direction: column; padding-right: 28px; }
  .stat-card .name { font-size: 14px; font-weight: 600; color: var(--accent); margin-bottom: 2px; }
  .stat-card .desc {
    font-size: 12px; color: var(--text2); line-height: 1.4; margin-bottom: 4px;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
  }
  .stat-card .detail { font-size: 11px; color: var(--text2); opacity: 0.6; margin-top: auto; }

  /* ── Search results ── */
  .meta { display: flex; gap: 12px; align-items: center; margin-bottom: 20px; font-size: 12px; color: var(--text2); }
  .meta .count { color: var(--amber); font-weight: 600; }
  .meta .time { opacity: 0.6; }

  .results { display: flex; flex-direction: column; gap: 3px; }
  .result {
    padding: 14px 16px; background: var(--surface); border-radius: var(--radius);
    cursor: pointer; border: 1px solid transparent;
    transition: all 0.2s; opacity: 0; animation: fadeUp 0.3s ease forwards;
  }
  .result:hover, .result:focus-visible {
    background: var(--surface2); border-color: var(--amber-border);
    box-shadow: 0 2px 12px var(--amber-glow);
    transform: translateX(4px); outline: none;
  }
  .result-source {
    display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
  }
  .result-source img { width: 20px; height: 20px; border-radius: 4px; flex-shrink: 0; background: #fff; }
  .result-source .rs-letter {
    width: 20px; height: 20px; border-radius: 4px; background: var(--surface2);
    display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 600; color: var(--text2); flex-shrink: 0;
  }
  .result-source .rs-name { font-size: 12px; color: var(--text2); font-weight: 500; }
  .result .title { font-size: 14px; font-weight: 600; color: var(--accent); margin-bottom: 4px; }
  .result .snippet {
    font-size: 13px; color: var(--text2); line-height: 1.5;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
    transition: opacity 0.3s;
  }

  @keyframes fadeUp {
    from { opacity: 0; transform: translateY(8px); }
    to { opacity: 1; transform: translateY(0); }
  }
  .result:nth-child(1) { animation-delay: 0s; }
  .result:nth-child(2) { animation-delay: 0.04s; }
  .result:nth-child(3) { animation-delay: 0.08s; }
  .result:nth-child(4) { animation-delay: 0.12s; }
  .result:nth-child(5) { animation-delay: 0.16s; }
  .result:nth-child(n+6) { animation-delay: 0.2s; }

  /* ── Load more ── */
  .load-more {
    text-align: center; padding: 16px;
  }
  .load-more button {
    padding: 10px 24px; font-size: 13px; font-weight: 500; cursor: pointer;
    background: var(--surface); color: var(--text); border: 1px solid var(--border);
    border-radius: var(--radius); transition: all 0.2s;
  }
  .load-more button:hover {
    border-color: var(--amber-border); background: var(--amber-glow);
  }

  /* ── Reader ── */
  .reader-view {
    position: fixed; top: 56px; left: 0; right: 0; bottom: 0;
    display: none; flex-direction: column; z-index: 100; background: var(--bg);
  }
  .reader-view.open { display: flex; animation: fadeIn 0.15s ease; }
  @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
  #reader-frame { flex: 1; border: none; width: 100%; background: #fff; }
  .reader-loading {
    position: absolute; inset: 0; display: flex; align-items: center;
    justify-content: center; flex-direction: column; gap: 12px;
    background: var(--bg); color: var(--text2); font-size: 13px;
    z-index: 5; transition: opacity 0.3s;
  }
  .reader-loading.hidden { opacity: 0; pointer-events: none; }
  .reader-loading .spinner {
    width: 24px; height: 24px; border: 2px solid var(--border);
    border-top-color: var(--amber); border-radius: 50%;
    animation: spin 0.7s linear infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }

  /* ── Footer ── */
  .footer {
    text-align: center; padding: 32px 16px 24px;
    font-size: 12px; color: var(--text2); opacity: 0.5;
    border-top: 1px solid var(--border); margin-top: 40px;
  }
  .footer .footer-brand {
    background: linear-gradient(135deg, #f59e0b, #f97316);
    -webkit-background-clip: text; -webkit-text-fill-color: transparent;
    background-clip: text; font-weight: 600; text-decoration: none;
  }
  .footer .footer-brand:hover { opacity: 0.8; }

  /* ── Utility ── */
  .loading { text-align: center; padding: 40px; color: var(--text2); font-size: 13px; }
  .loading .spinner-inline {
    display: inline-block; width: 14px; height: 14px;
    border: 2px solid var(--border); border-top-color: var(--amber);
    border-radius: 50%; animation: spin 0.7s linear infinite;
    vertical-align: middle; margin-right: 8px;
  }
  .content-search-indicator {
    text-align: center; padding: 12px; color: var(--text2); font-size: 12px;
    opacity: 0.7; animation: fadeIn 0.3s ease;
  }
  .content-search-indicator .spinner-inline {
    display: inline-block; width: 12px; height: 12px;
    border: 2px solid var(--border); border-top-color: var(--amber);
    border-radius: 50%; animation: spin 0.7s linear infinite;
    vertical-align: middle; margin-right: 6px;
  }
  .empty { text-align: center; padding: 48px 20px; color: var(--text2); }
  .empty p { font-size: 14px; margin-bottom: 8px; }
  .empty .hint { font-size: 12px; opacity: 0.5; }
  .search-deeper {
    text-align: center; padding: 14px 20px; margin-top: 16px;
    color: var(--amber); font-size: 13px; cursor: pointer;
    border: 1px dashed var(--border); border-radius: var(--radius);
    transition: all 0.2s;
  }
  .search-deeper:hover, .search-deeper:focus-visible {
    border-color: var(--amber-border); background: var(--surface);
    outline: none;
  }
  #main-view { min-height: calc(100vh - 56px); }
  #main-view.hidden { display: none; }

  /* ── Manage page ── */
  .manage-card {
    background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
    padding: 20px; margin-bottom: 16px;
  }
  .manage-card h2 {
    font-size: 14px; font-weight: 600; color: var(--accent); margin-bottom: 12px;
  }
  .manage-card .mc-row {
    display: flex; justify-content: space-between; align-items: center;
    font-size: 13px; color: var(--text2); padding: 4px 0;
  }
  .manage-card .mc-row .mc-label { color: var(--text2); }
  .manage-card .mc-row .mc-value { color: var(--text); font-weight: 500; }
  .manage-btn-action {
    padding: 8px 14px; font-size: 12px; font-weight: 500; cursor: pointer;
    background: linear-gradient(135deg, var(--amber), var(--amber2));
    color: #000; border: none; border-radius: var(--radius);
    transition: opacity 0.2s; white-space: nowrap;
  }
  .manage-btn-action:hover { opacity: 0.85; }
  .manage-btn-action:disabled { opacity: 0.4; cursor: default; }
  .manage-search { margin-bottom: 16px; display: flex; gap: 8px; flex-wrap: wrap; }
  .manage-search input {
    flex: 1; padding: 9px 14px; background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); color: var(--text); font-size: 14px; outline: none;
  }
  .manage-search input:focus { border-color: var(--amber-border); }
  .manage-search select {
    padding: 9px 12px; background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); color: var(--text); font-size: 13px; outline: none;
  }
  .catalog-item {
    display: flex; gap: 14px; align-items: center; padding: 14px 16px;
    background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
    margin-bottom: 6px; transition: border-color 0.2s, opacity 0.3s, max-height 0.3s, padding 0.3s, margin 0.3s;
    max-height: 200px; overflow: hidden;
  }
  .catalog-item:hover { border-color: var(--amber-border); }
  .catalog-item.ci-installed-item { opacity: 0.6; }
  .ci-icon { flex-shrink: 0; width: 40px; height: 40px; border-radius: 8px; overflow: hidden; background: var(--surface2); }
  .ci-icon img { width: 34px; height: 34px; object-fit: cover; background: #fff; border-radius: 6px; padding: 2px; }
  .ci-icon .ci-letter {
    display: flex; align-items: center; justify-content: center;
    width: 40px; height: 40px; font-size: 16px; font-weight: 700; color: var(--accent);
  }
  .catalog-item .ci-info { flex: 1; min-width: 0; }
  .catalog-item .ci-title { font-size: 14px; font-weight: 600; color: var(--accent); margin-bottom: 3px; }
  .catalog-item .ci-summary {
    font-size: 12px; color: var(--text2); line-height: 1.4; margin-bottom: 4px;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
  }
  .catalog-item .ci-meta { font-size: 12px; color: var(--text2); opacity: 0.5; word-spacing: 0.1em; }
  .catalog-item .ci-meta > span { white-space: nowrap; }
  .catalog-item .ci-actions { flex-shrink: 0; display: flex; align-items: center; gap: 6px; margin-left: 8px; }
  .ci-installed-badge {
    font-size: 11px; color: var(--amber); font-weight: 600; padding: 4px 10px;
    border: 1px solid var(--amber-border); border-radius: 12px; background: var(--amber-glow);
  }
  .ci-delete-btn {
    background: none; border: 1px solid transparent; color: var(--text2); opacity: 0.3;
    font-size: 16px; cursor: pointer; padding: 2px 6px; border-radius: 6px;
    transition: all 0.2s; line-height: 1;
  }
  .ci-delete-btn:hover { opacity: 1; color: #f44; border-color: #f44; }
  .ci-delete-btn.confirming { opacity: 1; color: #f44; border-color: #f44; font-size: 11px; padding: 4px 10px; }
  .ci-update-btn {
    font-size: 11px; font-weight: 600; padding: 4px 12px; cursor: pointer;
    color: #fff; background: var(--amber2); border: none; border-radius: 12px;
    transition: all 0.2s; white-space: nowrap;
  }
  .ci-update-btn:hover { background: var(--amber); }
  .ci-has-update { border-color: var(--amber-border); }
  .ci-add-btn {
    padding: 6px 14px; font-size: 12px; font-weight: 500; cursor: pointer;
    background: var(--surface2); color: var(--text); border: 1px solid var(--border);
    border-radius: var(--radius); transition: all 0.2s;
  }
  .ci-add-btn:hover { border-color: var(--amber-border); background: var(--amber-glow); }
  .ci-variants { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
  .ci-variant-btn {
    padding: 3px 10px; font-size: 11px; font-weight: 500; cursor: pointer;
    background: var(--surface2); color: var(--text2); border: 1px solid var(--border);
    border-radius: 12px; transition: all 0.2s; white-space: nowrap;
  }
  .ci-variant-btn:hover { border-color: var(--amber-border); background: var(--amber-glow); color: var(--text); }
  /* Flavor picker pill + popup */
  .flavor-pill {
    display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500;
    color: var(--text2); background: var(--surface2); border: 1px solid var(--border);
    border-radius: 12px; padding: 2px 8px 2px 8px; cursor: pointer; transition: all 0.2s;
    white-space: nowrap; position: relative;
  }
  .flavor-pill:hover { border-color: var(--amber-border); color: var(--text); background: var(--amber-glow); }
  .flavor-pill svg { opacity: 0.5; flex-shrink: 0; }
  .flavor-pill:hover svg { opacity: 0.8; }
  .flavor-popup {
    position: fixed; z-index: 1000; background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); box-shadow: 0 8px 32px rgba(0,0,0,0.5); padding: 12px;
    min-width: 220px; max-width: 300px;
  }
  .flavor-popup-title { font-size: 12px; font-weight: 600; color: var(--text); margin-bottom: 8px; }
  .flavor-option {
    display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 6px;
    cursor: pointer; transition: all 0.15s; border: 1px solid transparent; margin-bottom: 2px;
  }
  .flavor-option:hover { background: var(--surface2); border-color: var(--border); }
  .flavor-option.current { background: var(--amber-glow); border-color: var(--amber-border); cursor: default; }
  .flavor-option-label { flex: 1; font-size: 12px; font-weight: 500; color: var(--text); }
  .flavor-option-size { font-size: 11px; color: var(--text2); white-space: nowrap; }
  .flavor-option-check { width: 16px; color: var(--amber); font-weight: bold; text-align: center; flex-shrink: 0; }
  .flavor-popup-backdrop { position: fixed; inset: 0; z-index: 999; }
  .ci-section-label {
    font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
    color: var(--text2); padding: 16px 0 8px; opacity: 0.7;
  }
  .dl-item {
    padding: 12px 14px; background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); margin-bottom: 6px; font-size: 13px;
  }
  .dl-row { display: flex; gap: 12px; align-items: center; }
  .dl-item .dl-name { flex: 1; color: var(--text); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .dl-item .dl-size { color: var(--text2); flex-shrink: 0; }
  .dl-item .dl-done { color: var(--amber); font-weight: 600; }
  .dl-item .dl-error { color: #f87171; font-weight: 600; }
  .dl-progress { height: 4px; background: var(--surface2); border-radius: 2px; margin-top: 8px; overflow: hidden; }
  .dl-progress-bar { height: 100%; background: var(--amber); border-radius: 2px; transition: width 0.5s; }
  .dl-actions { display: flex; gap: 8px; align-items: center; margin-top: 6px; }
  .dl-cancel-btn, .dl-retry-btn { background: none; border: 1px solid var(--border); color: var(--text2);
    font-size: 11px; padding: 2px 8px; border-radius: 4px; cursor: pointer; }
  .dl-cancel-btn:hover { border-color: #f44; color: #f44; }
  .dl-retry-btn:hover { border-color: var(--amber); color: var(--amber); }
  .dl-meta { font-size: 11px; color: var(--text2); opacity: 0.6; }
  .dl-clear-btn {
    float: right; background: none; border: 1px solid var(--border); color: var(--text2);
    font-size: 11px; padding: 2px 10px; border-radius: 4px; cursor: pointer; margin-top: -28px;
  }
  .dl-clear-btn:hover { border-color: var(--amber); color: var(--amber); }

  /* ── Manage tabs ── */
  .manage-tabs {
    display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 16px;
    overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none;
  }
  .manage-tabs::-webkit-scrollbar { display: none; }
  .manage-tab {
    padding: 10px 20px; font-size: 13px; font-weight: 500; cursor: pointer;
    background: none; border: none; border-bottom: 2px solid transparent;
    color: var(--text2); transition: all 0.2s; white-space: nowrap;
  }
  .manage-tab:hover { color: var(--text); }
  .manage-tab.active {
    color: var(--amber); border-bottom-color: var(--amber); font-weight: 600;
  }
  .manage-tab-content { display: none; }
  .manage-tab-content.active { display: block; }
  .manage-installed-group { margin-bottom: 24px; }
  .manage-installed-group .ci-section-label { margin-top: 0; }
  .manage-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }

  /* ── Browse category gallery ── */
  .browse-gallery { }
  .browse-header { text-align: center; margin-bottom: 24px; }
  .browse-title { font-size: 18px; font-weight: 600; color: var(--accent); margin-bottom: 4px; }
  .browse-subtitle { font-size: 13px; color: var(--text2); }
  .browse-grid {
    display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 10px;
  }
  .browse-cat-card {
    display: flex; gap: 14px; align-items: stretch;
    padding: 18px; cursor: pointer;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); transition: all 0.2s;
  }
  .browse-cat-card:hover {
    border-color: var(--amber-border); background: var(--surface2); transform: translateY(-1px);
  }
  .bcc-icon {
    font-size: 28px; width: 48px; height: 48px;
    display: flex; align-items: center; justify-content: center;
    background: var(--surface2); border-radius: 12px; flex-shrink: 0;
    align-self: center;
  }
  .bcc-info { flex: 1; min-width: 0; display: flex; flex-direction: column; }
  .bcc-name { font-size: 15px; font-weight: 600; color: var(--accent); margin-bottom: 2px; }
  .bcc-desc { font-size: 12px; color: var(--text2); line-height: 1.4; }
  .bcc-count { font-size: 11px; color: var(--text2); opacity: 0.6; margin-top: auto; }
  .bcc-arrow {
    font-size: 20px; color: var(--text2); opacity: 0.3; flex-shrink: 0; transition: all 0.2s;
    align-self: center;
  }
  .browse-cat-card:hover .bcc-arrow { color: var(--amber); opacity: 1; }
  .browse-drilldown-header {
    display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
  }
  .browse-back {
    background: none; border: none; color: var(--amber);
    font-size: 13px; cursor: pointer; padding: 4px 0; font-weight: 500;
  }
  .browse-back:hover { text-decoration: underline; }
  .browse-drilldown-title { font-size: 15px; font-weight: 600; color: var(--accent); }
  .browse-drilldown-count { font-size: 12px; color: var(--text2); margin-left: auto; }

  /* ── Mobile ── */
  /* ── Results summary ── */
  .results-summary {
    text-align: center; padding: 16px 0 4px; font-size: 12px; color: var(--text2); opacity: 0.6;
  }

  /* ── Footer ── */
  .footer-sub { margin-top: 6px; font-size: 11px; opacity: 0.7; }
  .footer a { color: var(--amber); text-decoration: none; }
  .footer a:hover { text-decoration: underline; }
  .kbd-hints { opacity: 0.6; }

  /* ── Mobile ── */
  @media (max-width: 600px) {
    .topbar { padding: 0 10px; gap: 6px; }
    .topbar .logo { font-size: 16px; }
    .bc-icon img { width: 18px; height: 18px; }
    .bc-icon .bc-letter { width: 18px; height: 18px; font-size: 10px; }
    .stats-grid { grid-template-columns: 1fr; }
    .browse-grid { grid-template-columns: 1fr; }
    .container { padding: 20px 12px; }
    .topbar-search .kbd { display: none; }
    .kbd-hints { display: none; }
    .manage-search input { min-width: 0; }
    .manage-search select { flex-shrink: 0; }
    .manage-card { padding: 16px 12px; }
    .manage-tab { padding: 8px 14px; font-size: 12px; }
    body { overflow-x: hidden; }
  }

  /* ── Password modal ── */
  .pw-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:200; align-items:center; justify-content:center; }
  .pw-overlay.open { display:flex; }
  .pw-box { background:var(--surface); border:1px solid var(--border); border-radius:12px; padding:24px; width:320px; max-width:90vw; }
  .pw-box h3 { margin:0 0 12px; font-size:15px; color:var(--text); }
  .pw-box input[type="password"] { width:100%; padding:8px 10px; border-radius:var(--radius); border:1px solid var(--border); background:var(--bg); color:var(--text); font-size:14px; box-sizing:border-box; }
  .pw-box input[type="password"]:focus { outline:none; border-color:var(--amber); }
  .pw-box .pw-actions { display:flex; gap:8px; margin-top:12px; justify-content:flex-end; }
  .pw-box button { padding:6px 14px; border-radius:var(--radius); border:1px solid var(--border); background:var(--surface2); color:var(--text); cursor:pointer; font-size:13px; }
  .pw-box button.pw-primary { background:var(--amber); color:#000; border-color:var(--amber); font-weight:600; }
  .pw-box .pw-error { color:#ef4444; font-size:12px; margin-top:8px; display:none; }

  /* ── Desktop: onboarding + settings overlays ── */
  .desktop-overlay {
    display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75);
    z-index: 500; align-items: center; justify-content: center;
    backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
  }
  .desktop-overlay.open { display: flex; }
  .desktop-card {
    background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
    padding: 32px; width: 480px; max-width: 90vw;
    box-shadow: 0 24px 80px rgba(0,0,0,0.5);
  }
  .desktop-card h2 {
    font-size: 22px; font-weight: 700; margin: 0 0 8px;
    background: linear-gradient(135deg, #f59e0b, #f97316);
    -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
  }
  .desktop-card .subtitle { font-size: 13px; color: var(--text2); line-height: 1.5; margin-bottom: 24px; }
  .desktop-card label { display: block; font-size: 12px; font-weight: 600; color: var(--text2); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
  .desktop-card .field-row { display: flex; gap: 8px; margin-bottom: 16px; }
  .desktop-card input[type="text"], .desktop-card input[type="number"] {
    flex: 1; padding: 9px 12px; background: var(--bg); border: 1px solid var(--border);
    border-radius: var(--radius); color: var(--text); font-size: 14px; font-family: inherit;
  }
  .desktop-card input[type="text"]:focus, .desktop-card input[type="number"]:focus {
    outline: none; border-color: var(--amber-border);
    box-shadow: 0 0 0 3px var(--amber-glow);
  }
  .desktop-card input[readonly] { color: var(--text2); cursor: default; }
  .desktop-card .hint { font-size: 11px; color: var(--text2); opacity: 0.6; margin-top: -10px; margin-bottom: 16px; }
  .desktop-card .checkbox-row {
    display: flex; align-items: center; gap: 8px; margin-bottom: 20px;
    font-size: 13px; color: var(--text2); cursor: pointer;
  }
  .desktop-card .checkbox-row input { accent-color: var(--amber); }
  .desktop-card .btn-row { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
  .desktop-card .btn-primary {
    padding: 9px 24px; font-size: 14px; font-weight: 600; cursor: pointer;
    background: linear-gradient(135deg, var(--amber), var(--amber2));
    color: #000; border: none; border-radius: var(--radius); transition: opacity 0.2s;
  }
  .desktop-card .btn-primary:hover { opacity: 0.85; }
  .desktop-card .btn-primary:disabled { opacity: 0.4; cursor: default; }
  .desktop-card .btn-secondary {
    padding: 9px 18px; font-size: 13px; cursor: pointer;
    background: var(--surface2); color: var(--text); border: 1px solid var(--border);
    border-radius: var(--radius); transition: all 0.2s;
  }
  .desktop-card .btn-secondary:hover { border-color: var(--amber-border); }
  .desktop-card .btn-browse {
    padding: 9px 14px; font-size: 13px; cursor: pointer; white-space: nowrap;
    background: var(--surface2); color: var(--text); border: 1px solid var(--border);
    border-radius: var(--radius); transition: all 0.2s; flex-shrink: 0;
  }
  .desktop-card .btn-browse:hover { border-color: var(--amber-border); color: var(--amber); }
  .desktop-card .restart-msg,
  .settings-restart-msg {
    display: none; padding: 10px 14px; margin-top: 12px;
    background: rgba(245, 158, 11, 0.08); border: 1px solid var(--amber-border);
    border-radius: var(--radius); font-size: 13px; color: var(--amber);
    text-align: center;
  }
  .desktop-card .restart-msg.show,
  .settings-restart-msg.show { display: block; }

  /* ── Card category badge (inline in detail row) ── */
  .card-cat { font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }

  /* ── Star (favorite) toggle ── */
  .star-btn {
    position: absolute; top: 8px; right: 8px; z-index: 2;
    background: none; border: none; cursor: pointer; font-size: 18px; line-height: 1;
    color: var(--text2); opacity: 0.3; transition: all 0.2s; padding: 2px;
  }
  .star-btn:hover { opacity: 1; color: var(--amber); transform: scale(1.2); }
  .star-btn.starred { opacity: 1; color: var(--amber); }
  .stat-card { position: relative; }

  /* ── Context menu ── */
  #zim-ctx-menu {
    position: fixed; z-index: 9999; min-width: 180px;
    background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
    padding: 4px 0; display: none; user-select: none;
  }
  #zim-ctx-menu.visible { display: block; }
  .ctx-item {
    padding: 8px 16px; font-size: 13px; color: var(--text); cursor: pointer;
    transition: background 0.1s; white-space: nowrap; position: relative;
  }
  .ctx-item:hover { background: var(--amber-glow); color: var(--amber); }
  .ctx-item.danger:hover { background: rgba(239,68,68,0.08); color: #ef4444; }
  .ctx-sep { height: 1px; background: var(--border); margin: 4px 0; }
  .ctx-sub {
    display: none; position: absolute; left: 100%; top: -4px;
    min-width: 170px; background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
    padding: 4px 0; z-index: 10000;
  }
  .ctx-sub.flip-left { left: auto; right: 100%; }
  .ctx-item:hover > .ctx-sub { display: block; }

  /* ── Collections manage ── */
  .coll-form { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
  .coll-form input {
    flex: 1; min-width: 120px; padding: 8px 12px; background: var(--surface);
    border: 1px solid var(--border); border-radius: var(--radius);
    color: var(--text); font-size: 13px; outline: none;
  }
  .coll-form input:focus { border-color: var(--amber-border); }
  .coll-form button {
    padding: 8px 16px; font-size: 13px; font-weight: 500; cursor: pointer;
    background: linear-gradient(135deg, var(--amber), var(--amber2));
    color: #000; border: none; border-radius: var(--radius);
  }
  .coll-item {
    display: flex; align-items: center; gap: 12px; padding: 12px 14px;
    background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
    margin-bottom: 6px; font-size: 13px; cursor: pointer;
  }
  .coll-item:hover { border-color: var(--amber-border); }
  .coll-item .coll-name { font-weight: 600; color: var(--accent); }
  .coll-item .coll-zims { flex: 1; color: var(--text2); font-size: 12px; }
  .coll-item .coll-del {
    background: none; border: 1px solid transparent; color: var(--text2); opacity: 0.3;
    font-size: 16px; cursor: pointer; padding: 2px 6px; border-radius: 6px;
    transition: all 0.2s; line-height: 1;
  }
  .coll-item .coll-del:hover { opacity: 1; color: #f44; border-color: #f44; }

  /* ── Reduced motion ── */
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
  }
</style>
</head>
<body>

<nav class="topbar" aria-label="Main navigation">
  <div class="topbar-breadcrumb" onclick="bcClick(event)">
    <a id="logo" class="logo" onclick="goHome(event)">Zimi</a>
    <span id="bc-sep" class="bc-sep" style="display:none">/</span>
    <span id="bc-icon" class="bc-icon" style="display:none"></span>
  </div>
  <button id="back-btn" class="back-btn" aria-label="Go back" style="display:none">&#x2190;</button>
  <div id="history-trail"></div>
  <div class="topbar-search">
    <input type="text" id="q" placeholder="Search everything..." autofocus aria-label="Search" autocomplete="off">
    <svg class="search-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="11" cy="11" r="8"/><path stroke-linecap="round" d="m21 21-4.3-4.3"/></svg>
    <span class="kbd">/</span>
    <div id="suggest-dropdown" class="suggest-dropdown"></div>
  </div>
  <a href="#" id="random-btn" class="random-btn" title="Random article (R)" onclick="randomArticle(event)"><span class="dice">&#x1F3B2;</span></a>
  <a href="#" id="manage-btn" class="manage-btn" title="Library Manager" onclick="toggleManage(event)"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></a>
  <a id="newtab-btn" class="newtab-btn" href="#" target="_blank" rel="noopener" title="Open in new tab"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>

</nav>

<div id="pw-overlay" class="pw-overlay" onclick="if(event.target===this)closePwModal()">
  <form class="pw-box" onsubmit="event.preventDefault();submitPw()" autocomplete="off">
    <h3 id="pw-title">Enter password</h3>
    <input style="display:none" type="text" name="fakeuser" tabindex="-1" autocomplete="off">
    <input id="pw-input" type="password" placeholder="Password" autocomplete="new-password" data-1p-ignore onkeydown="if(event.key==='Enter'){event.preventDefault();submitPw()}">
    <div class="pw-error" id="pw-error"></div>
    <label id="pw-remember-row" style="display:flex;align-items:center;gap:6px;margin-top:10px;font-size:13px;color:var(--text2);cursor:pointer">
      <input type="checkbox" id="pw-remember" style="accent-color:var(--amber);flex-shrink:0"> <span>Remember me</span>
    </label>
    <div class="pw-actions" style="justify-content:space-between">
      <button type="button" id="pw-remove-btn" onclick="manageClearPassword()" style="display:none;color:var(--text2);font-size:12px">Remove</button>
      <span style="flex:1"></span>
      <button type="button" onclick="closePwModal()">Cancel</button>
      <button type="submit" class="pw-primary">OK</button>
    </div>
  </form>
</div>

<!-- Desktop: Onboarding overlay (first-run) -->
<div id="desktop-onboarding" class="desktop-overlay" onclick="if(event.target===this)return">
  <div class="desktop-card">
    <h2>Welcome to Zimi</h2>
    <p class="subtitle">Choose where to keep your offline knowledge archives.</p>
    <p class="subtitle" style="font-size:11px;opacity:0.5;margin-top:-16px;margin-bottom:24px">ZIM files can be large (Wikipedia is ~100 GB) — pick a drive with plenty of space.</p>
    <label>ZIM folder</label>
    <div class="field-row">
      <input type="text" id="onboard-path" readonly placeholder="Choose a folder...">
      <button class="btn-browse" onclick="desktopChooseFolder('onboard-path')">Choose Folder</button>
    </div>
    <div class="btn-row">
      <button class="btn-primary" id="onboard-start" onclick="desktopFinishOnboarding()" disabled>Get Started</button>
    </div>
  </div>
</div>

<!-- Server Settings overlay -->
<div id="settings-overlay" class="desktop-overlay" onclick="if(event.target===this)closeSettings()">
  <div class="desktop-card">
    <h2>Server Settings</h2>
    <p class="subtitle">Configure your Zimi server.</p>
    <label>ZIM folder</label>
    <div class="field-row">
      <input type="text" id="settings-dir" readonly placeholder="/path/to/zims">
      <button class="btn-browse" id="settings-browse-btn" onclick="settingsChooseFolder()" style="display:none">Choose Folder</button>
    </div>
    <label id="settings-port-label">Port</label>
    <div class="field-row" id="settings-port-row">
      <input type="number" id="settings-port" min="1024" max="65535" value="8899">
    </div>
    <div class="hint" id="settings-hint">Changes require a server restart to take effect.</div>
    <div id="settings-browser-link" style="display:none;margin-top:8px;margin-bottom:16px;padding:12px 14px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius)">
      <div style="display:flex;align-items:center;justify-content:space-between">
        <span id="settings-browser-label" style="font-size:12px;color:var(--text2)">Access from any browser on this computer</span>
        <a id="settings-browser-url" href="#" target="_blank" rel="noopener" style="color:var(--amber);font-size:13px;font-weight:600;text-decoration:none"></a>
      </div>
    </div>
    <div class="settings-restart-msg" id="settings-restart-msg"></div>
    <div class="btn-row">
      <button class="btn-secondary" onclick="settingsRefreshCache()">Refresh Cache</button>
      <span style="flex:1"></span>
      <button class="btn-secondary" onclick="closeSettings()">Cancel</button>
      <button class="btn-primary" id="settings-save-btn" onclick="settingsSave()">Save</button>
    </div>
  </div>
</div>

<noscript>
  <div style="max-width:520px;margin:40px auto;padding:20px;text-align:center;color:#e8e8ed">
    <h1 style="font-size:20px;margin-bottom:8px">Zimi</h1>
    <p>Zimi requires JavaScript to browse your offline knowledge library.</p>
    <p style="margin-top:8px;opacity:0.6;font-size:13px">The API endpoints (/search, /read, /list) work without JS.</p>
  </div>
</noscript>

<main id="main-view">
  <div class="container">
    <div id="stats-bar" class="stats-bar"></div>
    <div id="pills-bar" class="pills"></div>
    <div id="source-header" style="display:none"></div>
    <div class="meta" id="search-meta" style="display:none">
      <span class="count" id="search-count"></span>
      <span class="time" id="search-time"></span>
    </div>
    <div id="output"></div>
  </div>
  <footer id="footer" class="footer" style="display:none">
    <a href="https://github.com/epheterson/zimi" target="_blank" rel="noopener" class="footer-brand">Zimi</a> <span id="footer-version"></span>&middot; Powered by <a href="https://kiwix.org" target="_blank" rel="noopener">Kiwix</a>
    <div class="footer-sub"><span class="kbd-hints"><kbd>/</kbd> Search &middot; <kbd>R</kbd> Random &middot; <kbd>Esc</kbd> Back</span></div>
  </footer>
</main>

<div id="reader" class="reader-view">
  <div class="reader-loading" id="reader-loading"><div class="spinner"></div><span>Loading...</span></div>
  <iframe id="reader-frame" title="Article content" sandbox="allow-same-origin allow-scripts allow-popups allow-downloads"></iframe>
</div>
<div id="zim-ctx-menu"></div>

<script>
// ── State ──
let mode = 'home'; // 'home' | 'source' | 'search' | 'manage'
let currentSource = null;
let readerOpen = false;
let readerSource = null; // ZIM name of what's in reader (for topbar badge when no explicit source)
let sourceAutoReader = false; // true when enterSource auto-opened the reader (non-zimgit homepage)
let _popstateNoAutoReader = false; // suppress auto-reader on popstate navigation
let articleHistory = []; // [{zim, path, title, timestamp}] — max 50 entries
let currentArticle = null; // {zim, path} — what's currently in the reader
let zimsCache = null;
let manageEnabled = false;
let activeCategories = new Set();
let activeSourceFilters = new Set();
let allResults = {};
let searchController = null;
let searchTimer = null;
let suggestTimer = null;
let suggestItems = [];
let suggestIndex = -1;
let snippetController = null;
let collectionsCache = null; // {version, favorites, collections}
let _expandedCollection = null; // which collection is expanded for ZIM picking
let homeScope = null; // {type:'favorites'|'category'|'collection', label, zimNames:[]}

const RESULTS_PER_PAGE = 20;
let visibleResultCount = RESULTS_PER_PAGE;

function fmtSize(gb) { return gb < 0.1 ? Math.max(1, Math.round(gb * 1024)) + ' MB' : gb < 10 ? gb.toFixed(1) + ' GB' : Math.round(gb) + ' GB'; }
function fmtSizeHtml(gb) { if (gb < 0.1) return '<span class="num">' + Math.max(1, Math.round(gb * 1024)) + '</span> MB'; if (gb < 10) return '<span class="num">' + gb.toFixed(1) + '</span> GB'; return '<span class="num">' + Math.round(gb) + '</span> GB'; }
function pl(n, singular, plural) { return n + ' ' + (n === 1 ? singular : (plural || singular + 's')); }

// ── DOM refs ──
const q = document.getElementById('q');
const output = document.getElementById('output');
const mainView = document.getElementById('main-view');
const statsBar = document.getElementById('stats-bar');
const pillsBar = document.getElementById('pills-bar');
const sourceHeaderEl = document.getElementById('source-header');
const searchMeta = document.getElementById('search-meta');
const logoEl = document.getElementById('logo');
const backBtn = document.getElementById('back-btn');
const bcSep = document.getElementById('bc-sep');
const bcIcon = document.getElementById('bc-icon');
const randomBtn = document.getElementById('random-btn');
const manageBtnEl = document.getElementById('manage-btn');
const _gearSvg = manageBtnEl.innerHTML; // capture gear icon for toggle
const newtabBtn = document.getElementById('newtab-btn');
const suggestDropdown = document.getElementById('suggest-dropdown');
const footerEl = document.getElementById('footer');

// ── Manage password ──
let _manageToken = '';
let _pwResolve = null;
let _pwReject = null;

function manageFetch(url, opts) {
  opts = opts || {};
  if (_manageToken) {
    opts.headers = Object.assign({}, opts.headers, {'Authorization': 'Bearer ' + _manageToken});
  }
  return fetch(url, opts).then(function(res) {
    if (res.status === 401) {
      return new Promise(function(resolve) {
        _pwResolve = function(token) {
          _manageToken = token;
          if (document.getElementById('pw-remember').checked) {
            sessionStorage.setItem('zimi_manage_pw', token);
          }
          const retry = Object.assign({}, opts);
          retry.headers = Object.assign({}, retry.headers, {'Authorization': 'Bearer ' + token});
          resolve(fetch(url, retry));
        };
        openPwModal('Enter password');
      });
    }
    return res;
  });
}

function manageLogout() {
  _manageToken = '';
  sessionStorage.removeItem('zimi_manage_pw');
  toggleManage();
}

function openPwModal(title, opts) {
  document.getElementById('pw-title').textContent = title || 'Enter password';
  document.getElementById('pw-input').value = '';
  document.getElementById('pw-input').placeholder = (opts && opts.placeholder) || 'Password';
  document.getElementById('pw-input').autocomplete = (opts && opts.hideRemember) ? 'new-password' : 'current-password';
  document.getElementById('pw-error').style.display = 'none';
  document.getElementById('pw-remember-row').style.display = (opts && opts.hideRemember) ? 'none' : 'flex';
  document.getElementById('pw-remove-btn').style.display = 'none';
  document.getElementById('pw-overlay').classList.add('open');
  setTimeout(function() { document.getElementById('pw-input').focus(); }, 50);
}

function closePwModal() {
  document.getElementById('pw-overlay').classList.remove('open');
  if (_pwReject) { _pwReject(); _pwReject = null; }
  _pwResolve = null;
}

function submitPw() {
  const pw = document.getElementById('pw-input').value;
  if (!pw) return;
  if (_pwResolve) {
    _pwReject = null;  // prevent cancel on close
    _pwResolve(pw);
    closePwModal();
  }
}

// ── Topbar ──
function updateTopbar() {
  const activeSource = currentSource || readerSource;
  // Back button: show when user has navigated deep enough that the breadcrumb
  // icons (Zimi logo = home, source icon = ZIM top-level) aren't sufficient.
  // That means: article history exists (stepped into articles), scoped home
  // view, or search results. NOT shown for basic reader-open-from-source
  // (back = click source icon or Escape).
  const showBack = articleHistory.length > 0 || mode === 'search' || homeScope;
  backBtn.style.display = showBack ? 'flex' : 'none';

  // Breadcrumb: Zimi / [icon] — search bar shows source name as placeholder
  if (activeSource) {
    bcSep.style.display = 'inline';
    bcIcon.style.display = 'inline-flex';
    const info = zimsCache && zimsCache.find(z => z.name === activeSource);
    const title = _zimTitle(activeSource);
    bcIcon.title = title;
    if (info && info.has_icon) {
      bcIcon.innerHTML = '<img src="/w/' + encodeURIComponent(activeSource) + '/-/icon" width="22" height="22" alt="' + escAttr(title) + '">';
    } else {
      bcIcon.innerHTML = '<span class="bc-letter">' + (esc(title)[0] || 'Z').toUpperCase() + '</span>';
    }
  } else {
    bcSep.style.display = 'none';
    bcIcon.style.display = 'none';
    bcIcon.title = '';
  }

  // Manage: gear → X close button, keep breadcrumb as-is
  if (mode === 'manage') {
    manageBtnEl.style.display = 'flex';
    manageBtnEl.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
    manageBtnEl.title = 'Close';
    manageBtnEl.style.color = 'var(--amber)';
    manageBtnEl.style.borderColor = 'var(--amber-border)';
    manageBtnEl.style.background = 'var(--amber-glow)';
  } else {
    manageBtnEl.style.display = manageEnabled && !activeSource ? 'flex' : 'none';
    manageBtnEl.innerHTML = _gearSvg;
    manageBtnEl.title = 'Library Manager';
    manageBtnEl.style.color = '';
    manageBtnEl.style.borderColor = '';
    manageBtnEl.style.background = '';
  }

  newtabBtn.style.display = readerOpen ? 'flex' : 'none';
  randomBtn.style.display = 'flex';

  // Search placeholder
  if (currentSource) {
    q.placeholder = _zimTitle(currentSource);
  } else if (mode === 'manage') {
    if (manageTab === 'installed') {
      q.placeholder = 'Filter installed ZIMs...';
    } else if (manageCategoryFilter) {
      const catMeta = BROWSE_CATEGORIES.find(c => c.key === manageCategoryFilter);
      q.placeholder = 'Search in ' + (catMeta ? catMeta.name : manageCategoryFilter) + '...';
    } else {
      q.placeholder = 'Search catalog...';
    }
  } else if (homeScope) {
    q.placeholder = 'Search ' + homeScope.label + '...';
  } else {
    q.placeholder = 'Search everything...';
  }

  // Footer
  updateFooter();
}

function bcClick(e) {
  // Clicking the icon in the breadcrumb goes to the source view
  if (e.target.id === 'logo') return;
  if (currentSource && (readerOpen || mode === 'search')) {
    if (readerOpen) closeReader();
    enterSource(currentSource, false);
  }
}

function updateFooter() {
  if (readerOpen) {
    footerEl.style.display = 'none';
  } else {
    footerEl.style.display = '';
  }
}

// ── Init ──
async function init() {
  output.innerHTML = '<div class="loading"><span class="spinner-inline"></span>Loading library\u2026</div>';
  try {
    const res = await fetch('/list');
    zimsCache = await res.json();
  } catch(e) { zimsCache = []; }
  try {
    const mres = await fetch('/manage/status');  // plain fetch — don't prompt for password on homepage
    if (mres.ok) {
      const mdata = await mres.json();
      manageEnabled = !!mdata.manage_enabled;
    } else if (mres.status === 401) {
      manageEnabled = true;  // manage exists but needs password
    }
  } catch(e) {}
  // Fetch collections/favorites (public, no auth needed)
  try {
    const cres = await fetch('/collections');
    if (cres.ok) collectionsCache = await cres.json();
  } catch(e) {}
  // Fetch version for footer
  try {
    const hres = await fetch('/health');
    if (hres.ok) {
      const hdata = await hres.json();
      if (hdata.version) document.getElementById('footer-version').textContent = hdata.version + ' ';
    }
  } catch(e) {}
  // Set initial history state so browser back always has a valid SPA state
  if (!history.state) history.replaceState({ mode: 'home' }, '', location.href);
  route(false);
  _desktopCheckOnboarding();
}

function route(push) {
  const path = location.pathname;
  const search = location.search;
  const params = new URLSearchParams(search);
  if (params.get('manage') !== null && manageEnabled) { enterManage(); return; }
  if (path.startsWith('/w/')) {
    let rest;
    try { rest = decodeURIComponent(path.slice(3)); } catch(e) { rest = path.slice(3); }
    const slashIdx = rest.indexOf('/');
    const name = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
    const articlePath = slashIdx === -1 ? null : rest.slice(slashIdx + 1);
    if (name) {
      // If URL has a query, restore the search
      const qParam = params.get('q');
      if (qParam) {
        enterSource(name, false);
        q.value = qParam;
        doSearch(qParam, false);
        return;
      }
      // If URL has an article path, open the article directly
      if (articlePath) {
        enterSource(name, false);
        openArticle(name, articlePath);
        return;
      }
      enterSource(name, push);
      return;
    }
  }
  // Scoped home: /?scope=type:label
  const scopeParam = params.get('scope');
  if (scopeParam) {
    const state = history.state;
    if (state && state.scope) {
      // Restore scope from history state (has zimNames)
      enterScope(state.scope.type, state.scope.label, state.scope.zimNames, false);
    } else {
      // Reconstruct scope from URL param
      const [stype, ...rest] = scopeParam.split(':');
      const slabel = rest.join(':');
      const scopeZims = _resolveScopeZims(stype, slabel);
      if (scopeZims.length) {
        enterScope(stype, slabel, scopeZims, false);
      } else {
        enterHome(push);
      }
    }
    return;
  }
  // Global search: /?q=term
  const qParam = params.get('q');
  if (qParam) {
    enterHome(false);
    q.value = qParam;
    doSearch(qParam, false);
    return;
  }
  if (zimsCache && zimsCache.length === 1) {
    enterSource(zimsCache[0].name, false);
    history.replaceState(null, '', '/w/' + encodeURIComponent(zimsCache[0].name));
    return;
  }
  enterHome(push);
}

// ── Navigation ──
function goHome(e) {
  if (e) e.preventDefault();
  closeReader();
  currentSource = null;
  readerSource = null;
  sourceAutoReader = false;
  activeCategories.clear();
  enterHome(true);
}

function goBack() {
  if (readerOpen) {
    // Step back through article history before closing reader
    if (articleHistory.length > 0) {
      _stepBackToArticle(articleHistory.pop(), true);
      return;
    }
    closeReader();
    // Only go home if reader was auto-opened by enterSource AND user hasn't searched
    if (sourceAutoReader) {
      sourceAutoReader = false;
      currentSource = null;
      enterHome(true);
    }
    // Otherwise mainView still has content (search results, document list, etc)
    return;
  }
  // Not in reader — navigate up the breadcrumb
  if (mode === 'search' && currentSource) {
    // Back from scoped search → return to source
    enterSource(currentSource, true);
  } else {
    goHome(null);
  }
}

// ── History trail ──
var _historyTrail = document.getElementById('history-trail');
var _histLongPressTimer = null;
var _histLongPressActive = false; // true after long-press fires — suppresses the following click

function _showHistoryTrail() {
  if (articleHistory.length === 0) return; // nothing to show
  var h = '';
  for (var i = articleHistory.length - 1; i >= 0; i--) {
    var item = articleHistory[i];
    h += '<div class="history-item" data-idx="' + i + '">';
    h += '<span class="hi-source">' + esc(_zimTitle(item.zim)) + '</span>';
    h += '<span class="hi-title">' + esc(item.title || item.path) + '</span>';
    h += '</div>';
  }
  _historyTrail.innerHTML = h;
  // Position below the back button
  var btn = backBtn.getBoundingClientRect();
  _historyTrail.style.left = Math.max(4, btn.left) + 'px';
  _historyTrail.style.top = (btn.bottom + 4) + 'px';
  _historyTrail.classList.add('visible');
}

function _hideHistoryTrail() {
  _historyTrail.classList.remove('visible');
}

function _navigateHistoryItem(el) {
  var item = el.closest('.history-item');
  if (!item) return false;
  var idx = parseInt(item.dataset.idx, 10);
  if (isNaN(idx) || idx < 0 || idx >= articleHistory.length) return false;
  var target = articleHistory[idx];
  articleHistory.splice(idx);
  _hideHistoryTrail();
  currentArticle = null;
  openArticle(target.zim, target.path, target.title);
  return true;
}

// Click on trail item (for right-click-then-click flow)
_historyTrail.addEventListener('click', function(e) { _navigateHistoryItem(e.target); });

// Hover highlight during long-press drag
_historyTrail.addEventListener('mouseover', function(e) {
  var item = e.target.closest('.history-item');
  _historyTrail.querySelectorAll('.history-item').forEach(function(el) { el.classList.toggle('hover', el === item); });
});
_historyTrail.addEventListener('mouseleave', function() {
  _historyTrail.querySelectorAll('.history-item.hover').forEach(function(el) { el.classList.remove('hover'); });
});

// Long-press on back button (500ms) — show history trail, support slide-to-select
backBtn.addEventListener('mousedown', function(e) {
  if (e.button !== 0) return;
  _histLongPressActive = false;
  _histLongPressTimer = setTimeout(function() {
    _histLongPressTimer = null;
    _histLongPressActive = true;
    _showHistoryTrail();
  }, 500);
});
backBtn.addEventListener('mouseleave', function() {
  if (_histLongPressTimer) { clearTimeout(_histLongPressTimer); _histLongPressTimer = null; }
});

// On mouseup anywhere: if long-press trail is open, check if releasing over a trail item
document.addEventListener('mouseup', function(e) {
  if (!_histLongPressActive) {
    // Short press — cancel timer if still pending
    if (_histLongPressTimer) { clearTimeout(_histLongPressTimer); _histLongPressTimer = null; }
    return;
  }
  _histLongPressActive = false;
  // Check if released over a trail item
  var el = document.elementFromPoint(e.clientX, e.clientY);
  if (el && _historyTrail.contains(el)) {
    _navigateHistoryItem(el);
  } else {
    _hideHistoryTrail();
  }
});

backBtn.addEventListener('click', function(e) {
  if (_historyTrail.classList.contains('visible')) { _hideHistoryTrail(); e.preventDefault(); return; }
  goBack();
});

// Right-click on back button — show history trail
backBtn.addEventListener('contextmenu', function(e) {
  e.preventDefault();
  if (_histLongPressTimer) { clearTimeout(_histLongPressTimer); _histLongPressTimer = null; }
  _showHistoryTrail();
});

// Close history trail on outside click (for right-click flow)
document.addEventListener('click', function(e) {
  if (_historyTrail.classList.contains('visible') && !_historyTrail.contains(e.target) && e.target !== backBtn) {
    _hideHistoryTrail();
  }
});
// Close history trail when iframe gets focus (clicks inside iframe don't bubble to parent)
window.addEventListener('blur', function() {
  if (_historyTrail.classList.contains('visible')) _hideHistoryTrail();
});

function enterHome(push) {
  mode = 'home';
  currentSource = null;
  readerSource = null;
  sourceAutoReader = false;
  homeScope = null;
  articleHistory = [];
  currentArticle = null;
  q.value = '';
  searchMeta.style.display = 'none';
  sourceHeaderEl.style.display = 'none';
  activeSourceFilters.clear();
  hideSuggest();
  if (push) history.pushState({ mode: 'home' }, '', '/');
  updateTopbar();
  renderHome();
}

function _resolveScopeZims(type, label) {
  if (!zimsCache) return [];
  if (type === 'favorites') {
    return (collectionsCache && collectionsCache.favorites) || [];
  }
  if (type === 'category') {
    return zimsCache.filter(z => z.category === label).map(z => z.name);
  }
  if (type === 'collection' && collectionsCache && collectionsCache.collections) {
    const coll = Object.values(collectionsCache.collections).find(c => c.label === label);
    return coll ? (coll.zims || []) : [];
  }
  return [];
}

function enterScope(type, label, zimNames, push) {
  mode = 'home';
  currentSource = null;
  readerSource = null;
  sourceAutoReader = false;
  homeScope = { type, label, zimNames };
  q.value = '';
  searchMeta.style.display = 'none';
  sourceHeaderEl.style.display = 'none';
  activeSourceFilters.clear();
  hideSuggest();
  if (push) history.pushState({ mode: 'home', scope: homeScope }, '', '/?scope=' + encodeURIComponent(type + ':' + label));
  var t = label + ' \u2014 Zimi';
  document.title = t;
  _setWindowTitle(t);
  updateTopbar();
  renderHome();
}

async function enterSource(name, push) {
  const info = zimsCache && zimsCache.find(z => z.name === name);
  if (!info) { enterHome(push); return; }
  mode = 'source';
  currentSource = name;
  readerSource = null;
  sourceAutoReader = false;
  q.value = '';
  searchMeta.style.display = 'none';
  activeSourceFilters.clear();
  hideSuggest();
  if (push) history.pushState({ mode: 'source', source: name }, '', '/w/' + encodeURIComponent(name));
  var t = (info.title || name) + ' \u2014 Zimi';
  document.title = t;
  _setWindowTitle(t);
  updateTopbar();
  await renderSource(name);
}

// ── Render: Home ──
function renderHome(filter) {
  if (!zimsCache || zimsCache.length === 0) {
    statsBar.innerHTML = ''; statsBar.style.display = 'none';
    pillsBar.innerHTML = ''; pillsBar.style.display = 'none';
    output.innerHTML = '<div class="empty"><p>No knowledge sources found</p><p class="hint">Add ZIM files to get started</p>'
      + (manageEnabled ? '<a href="/?manage" onclick="event.preventDefault();enterManage();setTimeout(function(){switchManageTab(\'browse\')},50)" style="display:inline-block;margin-top:16px;color:var(--amber);font-weight:500;font-size:14px;text-decoration:none;border-bottom:1px solid var(--amber-border)">Catalog \u2192</a>' : '')
      + '</div>';
    return;
  }

  // Determine base ZIM set (scoped or all)
  let baseZims = zimsCache;
  if (homeScope) {
    const scopeSet = new Set(homeScope.zimNames);
    baseZims = zimsCache.filter(z => scopeSet.has(z.name));
  }

  // Filter ZIMs by title/name/description when filter text provided
  let zims = baseZims;
  if (filter) {
    const fl = filter.toLowerCase();
    const words = fl.split(/\s+/).filter(Boolean);
    zims = baseZims.filter(z => {
      const t = ((z.title || '') + ' ' + z.name + ' ' + (z.description || '')).toLowerCase();
      return words.every(w => t.includes(w));
    });
  }

  const totalEntries = baseZims.reduce((s, z) => s + (typeof z.entries === 'number' ? z.entries : 0), 0);
  const totalGb = baseZims.reduce((s, z) => s + z.size_gb, 0);

  const n = baseZims.length;
  if (filter && zims.length !== baseZims.length) {
    statsBar.innerHTML = '<span class="num">' + zims.length + '</span> of ' + n + ' source' + (n !== 1 ? 's' : '') +
      ' matching &ldquo;' + esc(filter) + '&rdquo;';
  } else {
    statsBar.innerHTML = '<span class="num">' + n + '</span> source' + (n !== 1 ? 's' : '') + ' &middot; ' +
      '<span class="num">' + totalEntries.toLocaleString() + '</span> article' + (totalEntries !== 1 ? 's' : '') + ' &middot; ' +
      fmtSizeHtml(totalGb);
  }
  statsBar.style.display = '';

  const groups = {};
  const sorted = zims.filter(z => z.entries !== '?').sort((a, b) => (b.entries || 0) - (a.entries || 0));
  sorted.forEach(z => {
    const key = z.category || '_uncategorized';
    if (!groups[key]) groups[key] = [];
    groups[key].push(z);
  });

  const cats = Object.keys(groups).filter(c => c !== '_uncategorized').sort();

  // No pills on home — sections provide the organization
  pillsBar.innerHTML = ''; pillsBar.style.display = 'none';

  let h = '';

  // Scope header with back link
  if (homeScope) {
    h += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
      '<a href="#" onclick="event.preventDefault();enterHome(true)" style="color:var(--text2);text-decoration:none;font-size:13px">\u2190 All Sources</a>' +
      '<span style="color:var(--border)">/</span>' +
      '<span style="font-size:13px;font-weight:600;color:var(--amber)">' + esc(homeScope.label) + '</span>' +
    '</div>';
  }

  // Show article search hint at the top when filtering
  if (filter) {
    const scopeEntries = baseZims.reduce((s, z) => s + (typeof z.entries === 'number' ? z.entries : 0), 0);
    h += '<div class="search-deeper" onclick="doSearch(\'' + escAttr(filter) + '\')" tabindex="0" ' +
      'onkeydown="if(event.key===\'Enter\')doSearch(\'' + escAttr(filter) + '\')">' +
      'Search ' + scopeEntries.toLocaleString() + ' articles for &ldquo;' + esc(filter) + '&rdquo; \u2192</div>';
  }

  // Favorites section at top (only on unscoped home)
  if (!homeScope) {
    const favNames = (collectionsCache && collectionsCache.favorites) || [];
    if (!filter && favNames.length > 0) {
      const favZims = favNames.map(n => zimsCache.find(z => z.name === n)).filter(Boolean);
      if (favZims.length > 0) {
        const favZimNames = favZims.map(z => z.name);
        h += '<div class="cat-heading clickable" onclick="enterScope(\'favorites\',\'\u2605 Favorites\',' + escJs(JSON.stringify(favZimNames)) + ',true)">\u2605 Favorites</div>';
        h += renderCardGrid(favZims, true, true);
      }
    }

    // Collections sections (if any, not when filtering)
    if (!filter && collectionsCache && collectionsCache.collections) {
      for (const [cname, coll] of Object.entries(collectionsCache.collections)) {
        const collZims = (coll.zims || []).map(n => zimsCache.find(z => z.name === n)).filter(Boolean);
        if (collZims.length > 0) {
          const collZimNames = collZims.map(z => z.name);
          h += '<div class="cat-heading clickable" onclick="enterScope(\'collection\',\'' + escJs(coll.label || cname) + '\',' + escJs(JSON.stringify(collZimNames)) + ',true)">' + esc(coll.label || cname) + '</div>';
          h += renderCardGrid(collZims, true, true);
        }
      }
    }
  }

  if (filter && zims.length === 0) {
    h += '<div class="empty"><p>No sources matching &ldquo;' + esc(filter) + '&rdquo;</p></div>';
  } else {
    cats.forEach(cat => {
      if (!homeScope) {
        const catZimNames = groups[cat].map(z => z.name);
        h += '<div class="cat-heading clickable" onclick="enterScope(\'category\',\'' + escJs(cat) + '\',' + escJs(JSON.stringify(catZimNames)) + ',true)">' + esc(cat) + '</div>';
      } else {
        h += '<div class="cat-heading">' + esc(cat) + '</div>';
      }
      h += renderCardGrid(groups[cat], true);
    });
    if (groups._uncategorized) {
      h += '<div class="cat-heading" style="opacity:0.5">Other</div>';
      h += renderCardGrid(groups._uncategorized, true);
    }
  }

  output.innerHTML = h;
}

function renderCardGrid(items, showStars, showCategory) {
  if (!items || !items.length) return '';
  const favs = (collectionsCache && collectionsCache.favorites) || [];
  return '<div class="stats-grid">' + items.map(z => {
    const icon = z.has_icon
      ? '<img src="/w/' + encodeURIComponent(z.name) + '/-/icon" width="48" height="48" loading="lazy">'
      : '<span class="icon-letter">' + esc(z.title || z.name)[0].toUpperCase() + '</span>';
    const isFav = favs.includes(z.name);
    const starHtml = showStars
      ? '<button class="star-btn' + (isFav ? ' starred' : '') + '" onclick="event.stopPropagation();toggleFavorite(\'' + escAttr(z.name) + '\')" title="' + (isFav ? 'Remove from favorites' : 'Add to favorites') + '">' + (isFav ? '\u2605' : '\u2606') + '</button>'
      : '';
    const catPrefix = showCategory && z.category ? '<span class="card-cat">' + esc(z.category) + '</span> &middot; ' : '';
    return '<div class="stat-card" tabindex="0" role="button" onclick="enterSource(\'' + escJs(z.name) + '\', true)" onkeydown="if(event.key===\'Enter\')enterSource(\'' + escJs(z.name) + '\', true)">' +
      starHtml +
      '<div class="card-icon">' + icon + '</div>' +
      '<div class="card-info">' +
        '<div class="name">' + esc(z.title || z.name) + '</div>' +
        (z.description ? '<div class="desc">' + esc(z.description) + '</div>' : '') +
        '<div class="detail">' + catPrefix + (typeof z.entries === 'number' ? z.entries.toLocaleString() + ' entries' : '') +
        ' &middot; ' + fmtSize(z.size_gb) + '</div>' +
      '</div></div>';
  }).join('') + '</div>';
}

async function toggleFavorite(zimName) {
  try {
    const res = await fetch('/favorites', {
      method: 'POST',
      headers: {'Content-Type': 'application/json', ..._manageToken ? {'Authorization': 'Bearer ' + _manageToken} : {}},
      body: JSON.stringify({zim: zimName})
    });
    const data = await res.json();
    if (data.favorites) {
      if (!collectionsCache) collectionsCache = {version: 1, favorites: [], collections: {}};
      collectionsCache.favorites = data.favorites;
    }
    // Re-render current view to update stars
    if (mode === 'home') renderHome();
    else if (mode === 'search') renderSearchResults(allResults, currentSource);
  } catch(e) {}
}

function toggleCategory(cat) {
  if (activeCategories.has(cat)) activeCategories.delete(cat);
  else activeCategories.add(cat);
  renderHome();
}

// ── Context menu for homepage ZIM cards ──
(function() {
  const menu = document.getElementById('zim-ctx-menu');
  if (!menu) return;
  let _ctxZim = null;
  let _ctxCard = null;
  let _ctxX = 0, _ctxY = 0;

  function closeCtx() { menu.classList.remove('visible'); _ctxZim = null; _ctxCard = null; }

  function posMenu(x, y) {
    menu.style.left = '0'; menu.style.top = '0';
    menu.classList.add('visible');
    var mw = menu.offsetWidth, mh = menu.offsetHeight;
    var finalX = Math.min(x, window.innerWidth - mw - 8);
    menu.style.left = finalX + 'px';
    menu.style.top = Math.min(y, window.innerHeight - mh - 8) + 'px';
    // Flip submenu left if menu is near right edge
    var sub = menu.querySelector('.ctx-sub');
    if (sub) {
      if (finalX + mw + 170 > window.innerWidth) sub.classList.add('flip-left');
      else sub.classList.remove('flip-left');
    }
  }

  function showMainMenu() {
    var zim = _ctxZim;
    var favs = (collectionsCache && collectionsCache.favorites) || [];
    var isFav = favs.includes(zim);
    var colls = (collectionsCache && collectionsCache.collections) || {};
    var h = '<div class="ctx-item" data-action="open">Open</div>';
    h += '<div class="ctx-item" data-action="newtab">Open in New Tab</div>';
    h += '<div class="ctx-sep"></div>';
    h += '<div class="ctx-item" data-action="favorite">' + (isFav ? 'Remove from Favorites' : 'Add to Favorites') + '</div>';
    // Collections submenu (hover to expand)
    h += '<div class="ctx-item">Collections \u203A<div class="ctx-sub">';
    for (var cName in colls) {
      var coll = colls[cName];
      var inColl = (coll.zims || []).indexOf(zim) >= 0;
      h += '<div class="ctx-item" data-action="toggle-coll" data-coll="' + escAttr(cName) + '">' +
        (inColl ? '\u2713 ' : '') + esc(coll.label || cName) + '</div>';
    }
    if (Object.keys(colls).length > 0) h += '<div class="ctx-sep"></div>';
    h += '<div class="ctx-item" data-action="new-collection">New Collection\u2026</div>';
    h += '</div></div>';
    if (manageEnabled) {
      h += '<div class="ctx-sep"></div>';
      h += '<div class="ctx-item danger" data-action="delete">Delete</div>';
    }
    menu.innerHTML = h;
    posMenu(_ctxX, _ctxY);
  }

  document.addEventListener('contextmenu', function(e) {
    var card = e.target.closest('.stat-card');
    if (!card) { closeCtx(); return; }
    e.preventDefault();
    // Prevent text selection
    window.getSelection().removeAllRanges();
    var m = (card.getAttribute('onclick') || '').match(/enterSource\('([^']+)'/);
    if (!m) return;
    _ctxZim = m[1];
    _ctxCard = card;
    _ctxX = e.clientX + 2;
    _ctxY = e.clientY + 2;
    showMainMenu();
  });

  // Prevent text selection on stat-cards during mousedown
  document.addEventListener('mousedown', function(e) {
    if (e.button === 2 && e.target.closest('.stat-card')) {
      e.preventDefault();
    }
  });

  menu.addEventListener('click', function(e) {
    var item = e.target.closest('[data-action]');
    if (!item) return;
    var action = item.dataset.action;
    var zim = _ctxZim;
    var card = _ctxCard;
    e.stopPropagation();

    if (action === 'open') {
      closeCtx(); enterSource(zim, true);
    } else if (action === 'newtab') {
      closeCtx(); window.open('/w/' + encodeURIComponent(zim), '_blank');
    } else if (action === 'favorite') {
      closeCtx(); toggleFavorite(zim);
    } else if (action === 'toggle-coll') {
      var collName = item.dataset.coll;
      closeCtx();
      var data = collectionsCache || {favorites: [], collections: {}};
      var coll = data.collections && data.collections[collName];
      if (!coll) return;
      var zims = coll.zims ? coll.zims.slice() : [];
      var idx = zims.indexOf(zim);
      if (idx >= 0) zims.splice(idx, 1); else zims.push(zim);
      manageFetch('/collections', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({name: collName, label: coll.label || collName, zims: zims})
      }).then(function(res) { if (res.ok) { coll.zims = zims; renderHome(); } });
    } else if (action === 'new-collection') {
      closeCtx();
      var name = prompt('Collection name:');
      if (!name || !name.trim()) return;
      var slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_');
      manageFetch('/collections', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({name: slug, label: name.trim(), zims: [zim]})
      }).then(function(res) { return res.json(); }).then(function() {
        fetch('/collections').then(function(r) { return r.json(); }).then(function(c) {
          collectionsCache = c; renderHome();
        });
      });
    } else if (action === 'delete') {
      closeCtx();
      var zinfo = zimsCache && zimsCache.find(function(z) { return z.name === zim; });
      if (!zinfo || !zinfo.file) return;
      if (!confirm('Delete "' + (zinfo.title || zim) + '"?')) return;
      // Optimistic: remove card immediately
      if (card) card.style.display = 'none';
      zimsCache = zimsCache.filter(function(z) { return z.name !== zim; });
      manageFetch('/manage/delete', {
        method: 'POST', headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({filename: zinfo.file})
      }).then(function() {
        renderHome();
      });
    }
  });

  document.addEventListener('click', function(e) { if (!menu.contains(e.target)) closeCtx(); });
  document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeCtx(); });
})();

// ── Render: Source ──
async function renderSource(name) {
  const info = zimsCache.find(z => z.name === name);
  if (!info) return;

  statsBar.style.display = 'none';
  pillsBar.innerHTML = ''; pillsBar.style.display = 'none';

  // Build source header HTML (shown only for catalog/empty ZIMs)
  const iconHtml = info.has_icon
    ? '<img src="/w/' + encodeURIComponent(name) + '/-/icon" width="64" height="64">'
    : '<span class="icon-letter" style="font-size:28px">' + esc(info.title || name)[0].toUpperCase() + '</span>';
  const headerHtml = '<div class="source-header">' +
    '<div class="sh-icon">' + iconHtml + '</div>' +
    '<div class="sh-info">' +
      '<h1>' + esc(info.title || name) + '</h1>' +
      '<div class="sh-meta">' + (typeof info.entries === 'number' ? info.entries.toLocaleString() + ' entries' : '') +
      ' &middot; ' + fmtSize(info.size_gb) + '</div>' +
      (info.description ? '<div class="sh-desc">' + esc(info.description) + '</div>' : '') +
    '</div></div>';

  // For ZIMs with a homepage, go straight to reader (skip intermediate page)
  // Unless navigating back via popstate — show the source page instead
  const isZimgit = name.startsWith('zimgit-');
  if (info.main_path && !isZimgit && !_popstateNoAutoReader) {
    sourceHeaderEl.style.display = 'none';
    output.innerHTML = '';
    sourceAutoReader = true;
    openReader('/w/' + encodeURIComponent(name) + '/' + info.main_path);
    return;
  }

  // Catalog ZIMs or ZIMs without main_path — show source header
  sourceHeaderEl.innerHTML = headerHtml;
  sourceHeaderEl.style.display = '';
  output.innerHTML = '<div class="loading"><span class="spinner-inline"></span>Loading...</div>';

  // Try catalog (zimgit PDF collections show document list)
  try {
    const res = await fetch('/catalog?zim=' + encodeURIComponent(name));
    const data = await res.json();
    if (mode !== 'source' || currentSource !== name) return; // stale
    if (data.documents && data.documents.length) {
      output.innerHTML = '<div class="cat-heading">' + esc(String(data.count)) + ' Documents</div>' +
        '<div class="results">' + data.documents.map((d, i) => {
          const hasPath = !!d.path;
          const clickAttr = hasPath ? ' onclick="openArticle(\'' + escJs(name) + '\', \'' + escJs(d.path) + '\', \'' + escJs(d.title || '') + '\')" onkeydown="if(event.key===\'Enter\')openArticle(\'' + escJs(name) + '\', \'' + escJs(d.path) + '\', \'' + escJs(d.title || '') + '\')"' : '';
          return '<div class="result"' + (hasPath ? ' tabindex="0" role="button"' : '') + ' style="animation-delay:' + (i * 0.04) + 's"' + clickAttr + '>' +
            '<div class="title">' + esc(d.title) + '</div>' +
            (d.description ? '<div class="snippet">' + esc(d.description) + '</div>' : '') +
          '</div>';
        }).join('') + '</div>';
      return;
    }
  } catch(e) {}

  if (mode !== 'source' || currentSource !== name) return;

  // No catalog and no main_path — show empty source page
  if (info.main_path) {
    sourceAutoReader = true;
    output.innerHTML = '';
    sourceHeaderEl.style.display = 'none';
    openReader('/w/' + encodeURIComponent(name) + '/' + info.main_path);
  } else if (info.entries === 0 || info.entries === '?') {
    output.innerHTML = '<div class="empty"><p>This source has no content</p><p class="hint">The ZIM file may be corrupted or empty</p></div>';
  } else {
    output.innerHTML = '';
  }
}

// ── Search ──
q.addEventListener('input', () => {
  clearTimeout(searchTimer);
  const val = q.value.trim();
  // Suggest (200ms debounce)
  clearTimeout(suggestTimer);
  if (val && val.length >= 2 && mode !== 'manage') {
    suggestTimer = setTimeout(() => fetchSuggestions(val), 200);
  } else {
    hideSuggest();
  }
  // Full search or clear
  if (!val) { hideSuggest(); clearSearch(); return; }
  if (mode === 'manage') {
    searchTimer = setTimeout(() => {
      if (manageTab === 'installed') {
        renderInstalled(val);
      } else {
        browseCatalogFilter(val);
      }
    }, 300);
    return;
  }
  // On homepage: live-filter ZIM sources; article search only on Enter
  if (!currentSource && (mode === 'home' || mode === 'search')) {
    mode = 'home';
    searchMeta.style.display = 'none';
    renderHome(val);
    return;
  }
  searchTimer = setTimeout(() => doSearch(val), 500);
});

q.addEventListener('keydown', e => {
  // Suggest keyboard navigation
  if (suggestDropdown.style.display !== 'none') {
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      suggestIndex = Math.min(suggestIndex + 1, suggestItems.length - 1);
      highlightSuggest();
      return;
    }
    if (e.key === 'ArrowUp') {
      e.preventDefault();
      suggestIndex = Math.max(suggestIndex - 1, -1);
      highlightSuggest();
      return;
    }
    if (e.key === 'Enter' && suggestIndex >= 0) {
      e.preventDefault();
      selectSuggest(suggestIndex);
      return;
    }
    if (e.key === 'Escape') {
      hideSuggest();
      return;
    }
  }
  if (e.key === 'Enter') {
    e.preventDefault();
    clearTimeout(searchTimer);
    clearTimeout(suggestTimer);
    hideSuggest();
    if (mode === 'manage') {
      if (manageTab === 'installed') {
        renderInstalled(q.value.trim());
      } else {
        const val = q.value.trim();
        if (val) { browseCatalogFilter(val); } else { renderBrowseGallery(); }
      }
      return;
    }
    doSearch(q.value.trim());
  }
});


async function doSearch(query, push) {
  if (push === undefined) push = true;
  if (!query) return;
  clearTimeout(suggestTimer);
  hideSuggest();

  // Reject all-stop-word queries (e.g. "what", "the", "is it")
  const STOPS = new Set(['a','an','and','are','as','at','be','by','for','from','has','have','how','i','in','is','it','its','my','not','of','on','or','so','that','the','this','to','was','we','what','when','where','which','who','will','with','you']);
  const meaningful = query.toLowerCase().split(/\s+/).filter(w => !STOPS.has(w));
  if (!meaningful.length) {
    mode = 'search';
    statsBar.style.display = 'none';
    pillsBar.innerHTML = ''; pillsBar.style.display = 'none';
    searchMeta.style.display = 'none';
    output.innerHTML = '<div class="empty"><p>Try more specific keywords</p><p class="hint">Common words like \u201c' + esc(query) + '\u201d match too many articles</p></div>';
    updateTopbar();
    return;
  }

  if (searchController) searchController.abort();
  searchController = new AbortController();

  const scope = currentSource;
  mode = 'search';
  sourceAutoReader = false; // user searched — don't auto-home on back
  visibleResultCount = RESULTS_PER_PAGE;

  // Close reader if open
  if (readerOpen) closeReader();

  mainView.classList.remove('hidden');
  if (!scope) sourceHeaderEl.style.display = 'none';
  statsBar.style.display = 'none';
  pillsBar.innerHTML = ''; pillsBar.style.display = 'none';
  searchMeta.style.display = 'none';
  output.innerHTML = '<div class="loading"><span class="spinner-inline"></span>Searching titles\u2026</div>';
  updateTopbar();

  let zimParam = scope ? '&zim=' + encodeURIComponent(scope) : '';
  if (!scope && homeScope) {
    zimParam = '&zim=' + encodeURIComponent(homeScope.zimNames.join(','));
  }
  const searchT0 = performance.now();
  const searchUrl = scope ? '/w/' + encodeURIComponent(scope) + '?q=' + encodeURIComponent(query) : '/?q=' + encodeURIComponent(query);

  try {
    // ── Progressive two-phase search: fast title matches first, then full FTS ──
    // Phase 1: fast title search (parallel per-ZIM, no lock contention)
    const r1 = await fetch('/search?q=' + encodeURIComponent(query) + '&limit=10' + zimParam + '&fast=1',
      { signal: searchController.signal });
    const d1 = await r1.json();
    const phase1Elapsed = ((performance.now() - searchT0) / 1000).toFixed(1);
    d1._clientElapsed = phase1Elapsed;
    d1._query = query;
    allResults = d1;
    if (push) {
      // Replace (not push) if we're already on a search page — prevents duplicate entries
      // from autosearch timer + Enter key both calling doSearch
      var hs = history.state;
      if (hs && hs.mode === 'search') {
        history.replaceState({ mode: 'search', query: query, source: scope }, '', searchUrl);
      } else {
        history.pushState({ mode: 'search', query: query, source: scope }, '', searchUrl);
      }
    }
    renderSearchResults(allResults, scope);

    if (d1.partial) {
      // Show honest progress: "N title matches (Xs) — searching content..."
      const titleCount = (d1.results || []).length;
      const indicator = document.createElement('div');
      indicator.className = 'content-search-indicator';
      indicator.id = 'fts-indicator';
      const msg = titleCount > 0
        ? titleCount + ' title match' + (titleCount !== 1 ? 'es' : '') + ' (' + phase1Elapsed + 's) \u2014 '
        : '';
      indicator.innerHTML = '<span class="spinner-inline"></span>' + msg + 'Searching article content\u2026';
      output.prepend(indicator);

      // Live elapsed timer
      const timerInterval = setInterval(() => {
        const el = document.getElementById('fts-indicator');
        if (!el) { clearInterval(timerInterval); return; }
        const now = ((performance.now() - searchT0) / 1000).toFixed(0);
        el.innerHTML = '<span class="spinner-inline"></span>' + msg + 'Searching article content\u2026 (' + now + 's)';
      }, 1000);

      // Phase 2: full Xapian FTS (sequential under _zim_lock, searches every ZIM)
      const r2 = await fetch('/search?q=' + encodeURIComponent(query) + '&limit=10' + zimParam,
        { signal: searchController.signal });
      const d2 = await r2.json();
      clearInterval(timerInterval);
      d2._clientElapsed = ((performance.now() - searchT0) / 1000).toFixed(1);
      d2._query = query;
      allResults = mergeSearchResults(d1, d2);
      renderSearchResults(allResults, scope);
    }
  } catch(e) {
    if (e.name === 'AbortError') return;
    output.innerHTML = '<div class="empty"><p>Search failed</p><p class="hint">Try again in a moment.</p></div>';
  }
}

function mergeSearchResults(phase1, phase2) {
  // Deduplicate by zim+path, keeping higher-scored version
  const seen = new Map();
  for (const r of (phase1.results || [])) {
    const key = r.zim + ':' + r.path;
    const existing = seen.get(key);
    if (!existing || r.score > existing.score) seen.set(key, r);
  }
  for (const r of (phase2.results || [])) {
    const key = r.zim + ':' + r.path;
    const existing = seen.get(key);
    if (!existing || r.score > existing.score) seen.set(key, r);
  }
  const merged = Array.from(seen.values()).sort((a, b) => b.score - a.score);

  // Merge by_source counts (take max from either phase)
  const bySource = { ...(phase1.by_source || {}) };
  for (const [k, v] of Object.entries(phase2.by_source || {})) {
    bySource[k] = Math.max(bySource[k] || 0, v);
  }

  return {
    results: merged,
    by_source: bySource,
    total: merged.length,
    elapsed: phase2.elapsed,
    partial: false,
    _clientElapsed: phase2._clientElapsed,
    _query: phase2._query,
  };
}

function _sourceIconHtml(zimName, size) {
  const info = zimsCache && zimsCache.find(z => z.name === zimName);
  const title = (info && info.title) || zimName;
  if (info && info.has_icon) {
    return '<img src="/w/' + encodeURIComponent(zimName) + '/-/icon" width="' + size + '" height="' + size + '">';
  }
  return '<span class="rs-letter">' + (esc(title)[0] || 'Z').toUpperCase() + '</span>';
}

function renderSearchResults(data, scope) {
  if (snippetController) { snippetController.abort(); snippetController = null; }
  let items = data.results || [];
  const bySource = data.by_source || {};
  const totalCount = data.total || items.length;

  // Source filter pills (global search only, multiple sources)
  const sourceNames = Object.keys(bySource);
  if (!scope && sourceNames.length > 1) {
    pillsBar.innerHTML = sourceNames.sort().map(s => {
      return '<button class="pill' + (activeSourceFilters.has(s) ? ' active' : '') +
        '" aria-pressed="' + activeSourceFilters.has(s) + '" onclick="toggleSourceFilter(\'' + escAttr(s) + '\')">' +
        esc(_zimTitle(s)) + ' (' + bySource[s] + ')</button>';
    }).join('');
    pillsBar.style.display = '';
  }

  if (activeSourceFilters.size > 0) {
    items = items.filter(r => activeSourceFilters.has(r.zim));
  }

  const displayElapsed = data._clientElapsed || (data.elapsed ? data.elapsed.toFixed(1) : null);
  document.getElementById('search-count').textContent = totalCount + ' results';
  document.getElementById('search-time').textContent = displayElapsed ? 'in ' + displayElapsed + 's' : '';
  searchMeta.style.display = items.length ? 'flex' : 'none';

  if (!items.length) {
    output.innerHTML = '<div class="empty"><p>No results found</p><p class="hint">Try different keywords</p></div>';
    return;
  }

  // Show matching ZIM sources above results (global search only)
  let zimMatchHtml = '';
  if (!scope && zimsCache && data._query) {
    const ql = data._query.toLowerCase();
    const qw = ql.split(/\s+/).filter(Boolean);
    const matches = zimsCache.filter(z => {
      const t = ((z.title || '') + ' ' + z.name + ' ' + (z.description || '')).toLowerCase();
      return qw.every(w => t.includes(w));
    });
    if (matches.length > 0 && matches.length <= 8) {
      zimMatchHtml = '<div class="stats-grid" style="margin-bottom:16px">' + matches.map(z => {
        const icon = z.has_icon
          ? '<img src="/w/' + encodeURIComponent(z.name) + '/-/icon" width="48" height="48" loading="lazy">'
          : '<span class="icon-letter">' + esc(z.title || z.name)[0].toUpperCase() + '</span>';
        return '<div class="stat-card" tabindex="0" role="button" onclick="enterSource(\'' + escJs(z.name) + '\', true)" onkeydown="if(event.key===\'Enter\')enterSource(\'' + escJs(z.name) + '\', true)">' +
          '<div class="card-icon">' + icon + '</div>' +
          '<div class="card-info">' +
            '<div class="name">' + esc(z.title || z.name) + '</div>' +
            (z.description ? '<div class="desc">' + esc(z.description) + '</div>' : '') +
            '<div class="detail">' + (typeof z.entries === 'number' ? z.entries.toLocaleString() + ' entries' : '') +
            ' &middot; ' + fmtSize(z.size_gb) + '</div>' +
          '</div></div>';
      }).join('') + '</div>';
    }
  }

  // Pagination: show only first visibleResultCount items
  const visible = items.slice(0, visibleResultCount);
  const remaining = items.length - visibleResultCount;

  let html = zimMatchHtml + '<div class="results">' + visible.map((r, i) => {
    const sourceRow = !scope
      ? '<div class="result-source">' + _sourceIconHtml(r.zim, 20) +
        '<span class="rs-name">' + esc(_zimTitle(r.zim)) + '</span></div>'
      : '';
    return '<div class="result" tabindex="0" role="button" data-zim="' + escAttr(r.zim) + '" data-path="' + escAttr(r.path) + '" style="animation-delay:' + (Math.min(i, 5) * 0.04) + 's" onclick="openArticle(\'' + escJs(r.zim) + '\', \'' + escJs(r.path) + '\', \'' + escJs(r.title || '') + '\')" onkeydown="if(event.key===\'Enter\')openArticle(\'' + escJs(r.zim) + '\', \'' + escJs(r.path) + '\', \'' + escJs(r.title || '') + '\')">' +
      sourceRow +
      '<div class="title">' + esc(r.title) + '</div>' +
      (r.snippet ? '<div class="snippet">' + esc(r.snippet) + '</div>' : '<div class="snippet" data-needs-snippet="1"></div>') +
      '</div>';
  }).join('') + '</div>';

  if (remaining > 0) {
    html += '<div class="load-more"><button onclick="showMoreResults()">Show ' +
      Math.min(RESULTS_PER_PAGE, remaining) + ' more results</button></div>';
  }

  if (displayElapsed) {
    html += '<div class="results-summary">Found ' + totalCount + ' results in ' + displayElapsed + 's</div>';
  }

  output.innerHTML = html;

  if (!scope) loadSnippets();
}

function showMoreResults() {
  visibleResultCount += RESULTS_PER_PAGE;
  renderSearchResults(allResults, currentSource);
}

function _zimTitle(name) {
  const info = zimsCache && zimsCache.find(z => z.name === name);
  return (info && info.title) || name;
}

// ── Async Snippets ──
async function loadSnippets() {
  if (snippetController) snippetController.abort();
  snippetController = new AbortController();
  const signal = snippetController.signal;

  const els = document.querySelectorAll('.result [data-needs-snippet="1"]');
  if (!els.length) return;

  const queue = Array.from(els);
  const concurrency = 4;
  let active = 0;
  let idx = 0;

  function next() {
    while (active < concurrency && idx < queue.length) {
      const el = queue[idx++];
      const card = el.closest('.result');
      if (!card) continue;
      const zim = card.dataset.zim;
      const path = card.dataset.path;
      if (!zim || !path) continue;
      active++;
      fetchSnippet(el, zim, path, signal).finally(() => { active--; next(); });
    }
  }
  next();
}

async function fetchSnippet(el, zim, path, signal) {
  try {
    const res = await fetch('/snippet?zim=' + encodeURIComponent(zim) + '&path=' + encodeURIComponent(path), { signal });
    const data = await res.json();
    if (data.snippet) {
      el.textContent = data.snippet;
      el.removeAttribute('data-needs-snippet');
      el.style.opacity = '0';
      requestAnimationFrame(() => { el.style.opacity = '1'; });
    } else {
      el.remove();
    }
  } catch(e) {
    if (e.name !== 'AbortError') el.remove();
  }
}

function toggleSourceFilter(s) {
  if (activeSourceFilters.has(s)) activeSourceFilters.delete(s);
  else activeSourceFilters.add(s);
  visibleResultCount = RESULTS_PER_PAGE;
  renderSearchResults(allResults, null);
}

function clearSearch() {
  activeSourceFilters.clear();
  if (snippetController) { snippetController.abort(); snippetController = null; }
  searchMeta.style.display = 'none';
  visibleResultCount = RESULTS_PER_PAGE;
  if (mode === 'manage') {
    if (manageTab === 'browse') {
      if (manageCategoryFilter) drillCategory(manageCategoryFilter);
      else renderBrowseGallery();
    } else {
      renderInstalledPills();
      renderInstalled();
    }
  } else if (currentSource) {
    mode = 'source';
    updateTopbar();
    renderSource(currentSource);
  } else {
    mode = 'home';
    updateTopbar();
    renderHome();
  }
}

// ── Suggest / Autocomplete ──
let suggestController = null;
let _suggestSeq = 0; // sequence counter to discard stale responses

async function fetchSuggestions(query) {
  // Cancel any in-flight suggest request
  if (suggestController) suggestController.abort();
  suggestController = new AbortController();
  const seq = ++_suggestSeq;

  const zimParam = currentSource ? '&zim=' + encodeURIComponent(currentSource) : '';
  try {
    const res = await fetch('/suggest?q=' + encodeURIComponent(query) + '&limit=6' + zimParam,
      { signal: suggestController.signal });
    const data = await res.json();
    // Discard if a newer request has been issued or input lost focus
    if (seq !== _suggestSeq || document.activeElement !== q) return;
    // data is {zim_name: [{path, title}, ...], ...}
    suggestItems = [];
    for (const [zim, items] of Object.entries(data)) {
      for (const item of items) {
        if (item.error) continue;
        suggestItems.push({ zim, path: item.path, title: item.title });
      }
    }
    // Deduplicate by title
    const seen = new Set();
    suggestItems = suggestItems.filter(s => {
      const key = s.title.toLowerCase();
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    }).slice(0, 6);

    if (suggestItems.length) showSuggest();
    else hideSuggest();
  } catch(e) {
    if (e.name !== 'AbortError') hideSuggest();
  }
}

// Close suggestions when clicking anywhere outside the search box/dropdown
document.addEventListener('mousedown', (e) => {
  if (!suggestDropdown.contains(e.target) && e.target !== q) {
    hideSuggest();
  }
});

function showSuggest() {
  suggestIndex = -1;
  suggestDropdown.innerHTML = suggestItems.map((s, i) =>
    '<div class="suggest-item" data-i="' + i + '" onmousedown="selectSuggest(' + i + ')">' +
    '<div class="sg-title">' + esc(s.title) + '</div>' +
    (!currentSource ? '<div class="sg-source">' + esc(_zimTitle(s.zim)) + '</div>' : '') +
    '</div>'
  ).join('');
  suggestDropdown.style.display = 'block';
}

function hideSuggest() {
  suggestDropdown.style.display = 'none';
  suggestIndex = -1;
  if (suggestController) { suggestController.abort(); suggestController = null; }
}

function highlightSuggest() {
  suggestDropdown.querySelectorAll('.suggest-item').forEach((el, i) => {
    el.classList.toggle('active', i === suggestIndex);
  });
}

function selectSuggest(i) {
  const s = suggestItems[i];
  if (!s) return;
  hideSuggest();
  q.value = s.title;
  openArticle(s.zim, s.path, s.title);
}

// ── Library Manager ──
function toggleManage(e) {
  if (e) e.preventDefault();
  if (mode === 'manage') {
    if (!sessionStorage.getItem('zimi_manage_pw')) _manageToken = '';
    goHome(e); return;
  }
  enterManage(e);
}

function enterManage(e) {
  if (e) e.preventDefault();
  if (!manageEnabled) return;
  mode = 'manage';
  currentSource = null;
  readerSource = null;
  sourceAutoReader = false;
  q.value = '';
  closeReader();
  statsBar.style.display = 'none';
  pillsBar.innerHTML = ''; pillsBar.style.display = 'none';
  searchMeta.style.display = 'none';
  sourceHeaderEl.style.display = 'none';
  hideSuggest();
  history.pushState({ mode: 'manage' }, '', '/?manage');
  updateTopbar();
  renderManage();
}

let manageTab = 'installed';
let manageCategoryFilter = null;
let catalogPage = 0;
let _catalogObserver = null;
let _catalogTotal = 0;
let _dlTimer = null;

// ── Browse gallery data layer ──
let _catalogCache = null;      // All catalog items (fetched once)
let _catalogCacheLang = null;   // Language of cached data
let _browseView = 'gallery';   // 'gallery' | 'drilldown' | 'search'
let _availableUpdates = {};    // keyed by installed filename → update info
let _mergedToOther = new Set(); // categories merged into Other (< MIN_BROWSE_CAT items)

// ── Browse category gallery metadata ──
const BROWSE_CATEGORIES = [
  { key: 'wikipedia',      name: 'Encyclopedias',       icon: '\u{1F30D}', desc: 'Wikipedia, Wiktionary, Wikiquote, and Wikimedia projects' },
  { key: 'stack_exchange', name: 'Q&A Communities',      icon: '\u{1F4AC}', desc: 'Stack Overflow, Ask Ubuntu, cooking, DIY, science' },
  { key: 'devdocs',        name: 'Dev & Tech Docs',      icon: '\u{1F4BB}', desc: 'Language references, Linux wikis, programming tutorials' },
  { key: 'ted',            name: 'Video & Talks',        icon: '\u{1F3AC}', desc: 'TED talks by topic \u2014 multilingual' },
  { key: 'education',      name: 'Education & How-To',   icon: '\u{1F393}', desc: 'Khan Academy, iFixit, WikiHow, CrashCourse, and more' },
  { key: 'gutenberg',      name: 'Books & Literature',   icon: '\u{1F4DA}', desc: 'Project Gutenberg \u2014 public domain books' },
  { key: 'medical',        name: 'Medical & Health',     icon: '\u{1FA7A}', desc: 'WikiMed, MedlinePlus, CDC, NHS, field medicine' },
  { key: 'survival',       name: 'Survival & Preparedness', icon: '\u{1F9ED}', desc: 'Emergency guides, prepping, off-grid, water treatment' },
  { key: 'gaming',         name: 'Gaming & Fandom',      icon: '\u{1F3AE}', desc: 'Minecraft, Pok\u00E9mon, D&D, Stardew Valley, and more' },
  { key: 'other',          name: 'Other',                icon: '\u{1F4E6}', desc: 'Miscellaneous and uncategorized archives' },
];

// Language display: "Multilingual (24 langs)" or "French" instead of raw codes
const LANG_NAMES = {
  eng:'English',fra:'French',deu:'German',spa:'Spanish',por:'Portuguese',ita:'Italian',
  rus:'Russian',ara:'Arabic',zho:'Chinese',jpn:'Japanese',kor:'Korean',hin:'Hindi',
  tur:'Turkish',pol:'Polish',nld:'Dutch',swe:'Swedish',vie:'Vietnamese',tha:'Thai',
  heb:'Hebrew',ell:'Greek',ron:'Romanian',hun:'Hungarian',fas:'Persian',ind:'Indonesian',
  ukr:'Ukrainian',ces:'Czech',dan:'Danish',fin:'Finnish',nor:'Norwegian',cat:'Catalan',
  mul:'Multilingual'
};
function formatLanguage(langStr) {
  if (!langStr) return '';
  const codes = langStr.toLowerCase().split(',').map(c => c.trim()).filter(Boolean);
  if (codes.length === 0) return '';
  if (codes.length === 1) {
    if (codes[0] === 'eng') return ''; // Don't show English when it's the only language
    return LANG_NAMES[codes[0]] || codes[0].toUpperCase();
  }
  // Multilingual
  if (codes.length <= 3) return codes.map(c => LANG_NAMES[c] || c.toUpperCase()).join(', ');
  return 'Multilingual (' + codes.length + ' langs)';
}

// OPDS category → browse category mapping (case-insensitive)
// All 17 known OPDS categories mapped to our 9 browse categories
const _OPDS_CAT_MAP = {
  'wikipedia':'wikipedia', 'wikibooks':'wikipedia', 'wikiquote':'wikipedia',
  'wikisource':'wikipedia', 'wikiversity':'wikipedia', 'wikinews':'wikipedia',
  'wikivoyage':'wikipedia', 'wiktionary':'wikipedia', 'vikidia':'wikipedia',
  'stack_exchange':'stack_exchange',
  'ted':'ted',
  'gutenberg':'gutenberg',
  'mooc':'education', 'phet':'education',
  'ifixit':'education',  // iFixit, WikiHow → Education & How-To
};

function autoCategorize(item) {
  const cat = (item.category || '').toLowerCase();
  const n = (item.name || '').toLowerCase();
  // 1. Map known OPDS categories (covers ~730 of 1074 items)
  if (_OPDS_CAT_MAP[cat]) return _OPDS_CAT_MAP[cat];
  // 2. For empty/other categories, classify by name (~344 items)
  // Wikipedia family
  if (/^wiki[a-z]|^wikt|mediawiki|openstreetmap-wiki/.test(n)) return 'wikipedia';
  // Dev & tech docs
  if (n.startsWith('devdocs_') || n.startsWith('coreyms_')) return 'devdocs';
  if (/freecodecamp|mdn_|^docs\.|^peps\.|php\.net|archlinux|alpinelinux/.test(n)) return 'devdocs';
  if (/gentoo|termux|postmarketos|neos-wiki|unrealircd|bitcoin|cheatography/.test(n)) return 'devdocs';
  if (/devhints|cloudflare|getbootstrap|gobyexample|dart\.dev|lesscss/.test(n)) return 'devdocs';
  if (/lua\.org/.test(n)) return 'devdocs';
  // Q&A
  if (/stackoverflow|stackexchange|askubuntu|superuser|serverfault/.test(n)) return 'stack_exchange';
  // Education (broad — catches tutorials, courses, how-to, reference)
  if (/libretexts|crashcourse|phet|khan|openstax|coursera|explainxkcd/.test(n)) return 'education';
  if (/planetmath|mspeekenbrink|voa_learning|prunelle|bogleheads|finiki/.test(n)) return 'education';
  if (/citizendium|metakgp|appropedia|internet-encyclopedia|artofproblemsolving/.test(n)) return 'education';
  if (/edutechwiki|womenshistory|chopin\.lib|hitchwiki|football/.test(n)) return 'education';
  if (/biology|statistic|stats|learning|genomics|energypedia|proofwiki|ethanweed/.test(n)) return 'education';
  if (/stacks\.math|ncase\.me|encyclopedie|diksha|ghana-bece|ncert/.test(n)) return 'education';
  if (/ifixit|wikihow|cooking|publicdomainrecipes/.test(n)) return 'education';
  if (/mutopia|blender\.org|theworldfactbook/.test(n)) return 'education';
  // Books & literature
  if (/gutenberg|booksdash|xkcd/.test(n)) return 'gutenberg';
  // TED & video
  if (/^ted_/.test(n)) return 'ted';
  // Medical
  if (/medicine|medlineplus|mdwiki|librepathology|wikem|cdc\.gov|skin-of-color/.test(n)) return 'medical';
  // Survival & preparedness
  if (/zimgit-|ready\.gov|survivorlibrary|urban-prepper|solar\.lowtech/.test(n)) return 'survival';
  if (/canadian_prep|armypubs|cd3wd|selfreliance|s2underground|usda-2015/.test(n)) return 'survival';
  // Gaming & fandom
  if (/dandwiki|evageeks|frackinuniverse|granbluefantasy/.test(n)) return 'gaming';
  if (/the_infosphere|zdoom|westeros/.test(n)) return 'gaming';
  if (/minecraft|pokemon|bulba|stardew|rimworld|riskofrain|whitewolf/.test(n)) return 'gaming';
  return 'other';
}

async function loadFullCatalog() {
  const lang = (document.getElementById('catalog-lang') || {}).value || '';
  if (_catalogCache && _catalogCacheLang === lang) return _catalogCache;

  const res = await manageFetch('/manage/catalog?lang=' + encodeURIComponent(lang) + '&count=500&start=0');
  const data = await res.json();
  if (data.error) throw new Error(data.error);
  const total = data.total || 0;
  let items = data.items || [];

  // Fetch remaining pages in parallel if needed
  if (total > 500) {
    const fetches = [];
    for (let start = 500; start < total; start += 500) {
      fetches.push(
        manageFetch('/manage/catalog?lang=' + encodeURIComponent(lang) + '&count=500&start=' + start)
          .then(r => r.json()).then(d => d.items || [])
      );
    }
    const pages = await Promise.all(fetches);
    for (const page of pages) items = items.concat(page);
  }

  // Auto-categorize uncategorized items
  for (const item of items) {
    item.category = autoCategorize(item);
  }

  // Enrich with installed ZIM metadata by matching filename prefix to catalog name
  // OPDS catalog name: "wiktionary_en_all", installed filename: "wiktionary_en_all_maxi_2024-05.zim"
  if (zimsCache) {
    for (const item of items) {
      for (const z of zimsCache) {
        const fb = (z.file || '').replace(/\.zim$/, '');
        if (fb.startsWith(item.name + '_') || fb === item.name) {
          item.installed = true;
          const m = fb.match(/(\d{4}-\d{2})$/);
          item._installedDate = m ? m[1] : null;
          item._installedFile = z.file;
          item._installedName = z.name;
          item._installedSizeGb = z.size_gb;
          break;
        }
      }
    }
  }

  _catalogCache = items;
  _catalogCacheLang = lang;
  return _catalogCache;
}

function renderBrowseGallery() {
  const results = document.getElementById('catalog-results');
  if (!results) return;
  _browseView = 'gallery';
  manageCategoryFilter = null;
  pillsBar.innerHTML = ''; pillsBar.style.display = 'none';
  q.placeholder = 'Search catalog...';

  results.innerHTML = '<div class="loading"><span class="spinner-inline"></span>Loading library...</div>';

  loadFullCatalog().then(items => {
    // Count unique ZIMs per category (group variants by name)
    const catNames = {};    // cat -> Set of names
    const catInstalled = {};
    for (const item of items) {
      const cat = item.category || 'other';
      if (!catNames[cat]) catNames[cat] = new Set();
      catNames[cat].add(item.name);
      if (item.installed) catInstalled[cat] = (catInstalled[cat] || 0) + 1;
    }
    const catCounts = {};
    for (const [cat, names] of Object.entries(catNames)) catCounts[cat] = names.size;

    // Build gallery cards in BROWSE_CATEGORIES order
    const knownKeys = new Set(BROWSE_CATEGORIES.map(c => c.key));
    // Collect unknown categories into "other"
    let otherExtra = 0, otherInstalledExtra = 0;
    for (const [cat, count] of Object.entries(catCounts)) {
      if (!knownKeys.has(cat)) {
        otherExtra += count;
        otherInstalledExtra += (catInstalled[cat] || 0);
      }
    }
    if (otherExtra > 0) catCounts['other'] = (catCounts['other'] || 0) + otherExtra;
    if (otherInstalledExtra > 0) catInstalled['other'] = (catInstalled['other'] || 0) + otherInstalledExtra;

    // Merge small categories (< 3 items) into Other
    const MIN_BROWSE_CAT = 3;
    _mergedToOther = new Set();
    for (const cat of BROWSE_CATEGORIES) {
      if (cat.key === 'other') continue;
      const count = catCounts[cat.key] || 0;
      if (count > 0 && count < MIN_BROWSE_CAT) {
        _mergedToOther.add(cat.key);
        catCounts['other'] = (catCounts['other'] || 0) + count;
        catInstalled['other'] = (catInstalled['other'] || 0) + (catInstalled[cat.key] || 0);
        catCounts[cat.key] = 0;
      }
    }

    let h = '<div class="browse-gallery">';
    h += '<div class="browse-subtitle" style="text-align:center;margin:12px 0 8px">' + items.length.toLocaleString() + ' ZIMs available across ' +
      BROWSE_CATEGORIES.length + ' categories</div>';
    h += '<div class="browse-grid">';

    for (const cat of BROWSE_CATEGORIES) {
      const count = catCounts[cat.key] || 0;
      if (count === 0 && cat.key !== 'other') continue;
      const installed = catInstalled[cat.key] || 0;
      const countLine = count + ' available' + (installed > 0 ? ' \u00B7 ' + installed + ' installed' : '');
      h += '<div class="browse-cat-card" onclick="drillCategory(\'' + escAttr(cat.key) + '\')">' +
        '<div class="bcc-icon">' + cat.icon + '</div>' +
        '<div class="bcc-info">' +
          '<div class="bcc-name">' + esc(cat.name) + '</div>' +
          '<div class="bcc-desc">' + esc(cat.desc) + '</div>' +
          '<div class="bcc-count">' + esc(countLine) + '</div>' +
        '</div>' +
        '<div class="bcc-arrow">\u203A</div>' +
      '</div>';
    }
    h += '</div></div>';
    results.innerHTML = h;
  }).catch(err => {
    results.innerHTML = '<div class="empty"><p>Failed to load library</p><div class="hint">' + esc(String(err)) + '</div></div>';
  });
}

function drillCategory(catKey) {
  const results = document.getElementById('catalog-results');
  if (!results) return;
  _browseView = 'drilldown';
  manageCategoryFilter = catKey;
  pillsBar.innerHTML = ''; pillsBar.style.display = 'none';

  const catMeta = BROWSE_CATEGORIES.find(c => c.key === catKey);
  const catName = catMeta ? catMeta.name : catKey;
  q.placeholder = 'Search in ' + catName + '...';
  q.value = '';

  results.innerHTML = '<div class="loading"><span class="spinner-inline"></span>Loading...</div>';

  loadFullCatalog().then(items => {
    // Filter to this category (+ unknown/merged cats go to "other")
    const knownKeys = new Set(BROWSE_CATEGORIES.map(c => c.key));
    const filtered = items.filter(item => {
      const cat = item.category || 'other';
      if (catKey === 'other') return cat === 'other' || !knownKeys.has(cat) || _mergedToOther.has(cat);
      return cat === catKey;
    });

    const grouped = groupVariants(filtered);
    // Sort: installed first, then alphabetical
    grouped.sort((a, b) => (a.title || a.name || '').localeCompare(b.title || b.name || ''));

    let h = '<div class="browse-drilldown-header">' +
      '<button class="browse-back" onclick="renderBrowseGallery()">\u2190 Catalog</button>' +
      '<span class="browse-drilldown-title">' + (catMeta ? catMeta.icon + ' ' : '') + esc(catName) + '</span>' +
      '<span class="browse-drilldown-count">' + grouped.length + ' available</span>' +
    '</div>';
    for (const item of grouped) h += renderCatalogItem(item);
    if (!grouped.length) h += '<div class="empty"><p>No ZIMs in this category</p></div>';
    results.innerHTML = h;
  }).catch(err => {
    results.innerHTML = '<div class="empty"><p>Failed to load category</p></div>';
  });
}

function browseCatalogFilter(query) {
  if (!query) { renderBrowseGallery(); return; }
  const results = document.getElementById('catalog-results');
  if (!results) return;
  _browseView = 'search';
  pillsBar.innerHTML = ''; pillsBar.style.display = 'none';

  if (!_catalogCache) {
    results.innerHTML = '<div class="loading"><span class="spinner-inline"></span>Loading...</div>';
  }
  loadFullCatalog().then(items => {
    const lq = query.toLowerCase();
    const knownKeys = new Set(BROWSE_CATEGORIES.map(c => c.key));
    // Filter within current category if drilled down, otherwise all
    let pool = items;
    if (manageCategoryFilter) {
      pool = items.filter(item => {
        const cat = item.category || 'other';
        if (manageCategoryFilter === 'other') return cat === 'other' || !knownKeys.has(cat) || _mergedToOther.has(cat);
        return cat === manageCategoryFilter;
      });
    }
    const filtered = pool.filter(item => {
      const title = (item.title || item.name || '').toLowerCase();
      const summary = (item.summary || '').toLowerCase();
      return title.includes(lq) || summary.includes(lq) || (item.name || '').toLowerCase().includes(lq);
    });
    const grouped = groupVariants(filtered);
    grouped.sort((a, b) => (a.title || a.name || '').localeCompare(b.title || b.name || ''));
    let h = '<div class="browse-drilldown-header">' +
      '<button class="browse-back" onclick="' + (manageCategoryFilter ? "drillCategory('" + escAttr(manageCategoryFilter) + "')" : 'renderBrowseGallery()') + '">\u2190 Back</button>' +
      '<span class="browse-drilldown-count">' + filtered.length + ' results for \u201C' + esc(query) + '\u201D</span>' +
    '</div>';
    for (const item of grouped) h += renderCatalogItem(item);
    if (!grouped.length) h += '<div class="empty"><p>No matching ZIMs</p></div>';
    results.innerHTML = h;
  }).catch(() => {
    results.innerHTML = '<div class="empty"><p>Failed to search catalog</p></div>';
  });
}

// ── Category mapping (mirrors server _categorize_zim) ──
const MANAGE_CATEGORIES = [
  'Wikimedia', 'Stack Exchange', 'Dev Docs', 'Education', 'Medical', 'How-To', 'Books', 'Other'
];

function categorizeZim(name) {
  const n = name.toLowerCase();
  if (/medicine|wikem|ready\.gov/.test(n) || (n.startsWith('zimgit-') && /water|food|disaster|knots/.test(n))) return 'Medical';
  if (/stackoverflow|askubuntu|superuser|serverfault|stackexchange/.test(n)) return 'Stack Exchange';
  if (n.startsWith('devdocs_') || n === 'freecodecamp') return 'Dev Docs';
  if (/^ted_|^phzh_/.test(n) || /crashcourse|phet|appropedia|artofproblemsolving|edutechwiki|explainxkcd|coreeng/.test(n)) return 'Education';
  if (/wikihow|ifixit|off-the-grid/.test(n)) return 'How-To';
  if (/^wiki|^wikt/.test(n) || n === 'openstreetmap-wiki') return 'Wikimedia';
  if (/gutenberg|rationalwiki|theworldfactbook/.test(n)) return 'Books';
  return 'Other';
}

function formatSize(bytes) {
  if (!bytes) return '0 MB';
  const gb = bytes / (1024 ** 3);
  return gb >= 1 ? gb.toFixed(1) + ' GB' : (bytes / (1024 ** 2)).toFixed(0) + ' MB';
}

function groupVariants(items) {
  const groups = {};
  for (const item of items) {
    const key = item.name;
    if (!groups[key]) groups[key] = { ...item, variants: [] };
    groups[key].variants.push(item);
    if (item.article_count > (groups[key].article_count || 0)) groups[key].article_count = item.article_count;
    if (item.summary && !groups[key].summary) groups[key].summary = item.summary;
    if (item.icon_url && !groups[key].icon_url) groups[key].icon_url = item.icon_url;
  }
  return Object.values(groups);
}

function variantLabel(url) {
  if (!url) return '';
  const f = url.split('/').pop() || '';
  if (f.includes('_maxi_')) return 'Full';
  if (f.includes('_nopic_')) return 'No images';
  if (f.includes('_mini_')) return 'Mini';
  return '';
}

function renderCatalogItem(group) {
  const item = group;
  const variants = group.variants || [item];
  const meta = [];
  if (item.article_count) meta.push(item.article_count.toLocaleString() + ' articles');
  const langDisplay = formatLanguage(item.language);
  if (langDisplay) meta.push(langDisplay);
  if (item.date) meta.push(item.date);
  const sizes = variants.map(v => v.size_bytes).sort((a, b) => a - b);
  if (sizes.length > 1) {
    meta.push(formatSize(sizes[0]) + ' \u2013 ' + formatSize(sizes[sizes.length - 1]));
  } else {
    meta.push(formatSize(sizes[0]));
  }
  const iconHtml = item.icon_url
    ? '<img src="' + escAttr(item.icon_url) + '" width="40" height="40" loading="lazy">'
    : '<span class="ci-letter">' + (esc(item.title || item.name)[0] || '?').toUpperCase() + '</span>';
  const anyInstalled = variants.some(v => v.installed);
  let actionsHtml = '';
  if (anyInstalled) {
    const instVariant = variants.find(v => v.installed) || item;
    const hasUpdate = instVariant._installedDate && item.date && instVariant._installedDate < item.date.substring(0, 7);
    if (hasUpdate) {
      // Update available — show update button (same style as manage view)
      const updUrl = variants.find(v => v.download_url && !v.installed);
      actionsHtml = '<button class="ci-update-btn" onclick="downloadZim(\'' + escAttr((updUrl || item).download_url) + '\', this, true)" title="From ' + escAttr(instVariant._installedDate) + ' \u2192 ' + escAttr(item.date) + '">' +
        'Update</button>';
    } else {
      actionsHtml = '<span class="ci-installed-badge">Installed</span>';
    }
    if (instVariant._installedFile) {
      actionsHtml += '<button class="ci-delete-btn" onclick="deleteZim(\'' + escAttr(instVariant._installedFile) + '\', this)" title="Delete this ZIM file">\u00D7</button>';
    }
  } else {
    const withLabels = variants.filter(v => v.download_url).map(v => {
      const label = variantLabel(v.download_url) || 'Full';
      const size = formatSize(v.size_bytes);
      return { label, size, url: v.download_url };
    });
    const order = { 'Mini': 0, 'No images': 1, 'Full': 2 };
    withLabels.sort((a, b) => (order[a.label] ?? 1) - (order[b.label] ?? 1));
    const btns = withLabels.map(v =>
      '<button class="ci-variant-btn" onclick="downloadZim(\'' + escAttr(v.url) + '\', this)">' + esc(v.label + ' (' + v.size + ')') + '</button>'
    );
    if (btns.length) actionsHtml = '<div class="ci-variants">' + btns.join('') + '</div>';
  }
  const catAttr = item.category ? ' data-category="' + escAttr(item.category) + '"' : '';
  const instName = anyInstalled ? (variants.find(v => v.installed && v._installedName) || {})._installedName || '' : '';
  const openAttr = instName ? ' style="cursor:pointer" onclick="if(!event.target.closest(\'button\')){enterSource(\'' + escJs(instName) + '\',true)}"' : '';
  return '<div class="catalog-item' + (anyInstalled ? ' ci-installed-item' : '') + '"' + catAttr + openAttr + '>' +
    '<div class="ci-icon">' + iconHtml + '</div>' +
    '<div class="ci-info">' +
      '<div class="ci-title">' + esc(item.title || item.name) + '</div>' +
      (item.summary ? '<div class="ci-summary">' + esc(item.summary) + '</div>' : '') +
      '<div class="ci-meta">' + meta.map(function(m){return '<span>'+m+'</span>'}).join(' &middot; ') + '</div>' +
    '</div>' +
    '<div class="ci-actions">' + actionsHtml + '</div>' +
  '</div>';
}

// ── Tab switching ──
function switchManageTab(tab) {
  manageTab = tab;
  manageCategoryFilter = null;
  _browseView = 'gallery';
  document.querySelectorAll('.manage-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
  document.querySelectorAll('.manage-tab-content').forEach(c => c.classList.toggle('active', c.id === 'manage-' + tab));
  q.value = '';
  pillsBar.innerHTML = ''; pillsBar.style.display = 'none';
  if (tab === 'installed') {
    q.placeholder = 'Filter installed ZIMs...';
    renderInstalled();
  } else if (tab === 'collections') {
    q.placeholder = 'Search everything...';
    renderCollectionsTab();
  } else if (tab === 'history') {
    q.placeholder = 'Search everything...';
    renderHistoryTab();
  } else if (tab === 'activity') {
    q.placeholder = 'Search everything...';
    renderActivityTab();
  } else {
    renderBrowseGallery();
  }
}

// ── Collections tab ──
async function renderCollectionsTab() {
  const el = document.getElementById('manage-collections');
  if (!el) return;
  // Fetch fresh collections
  try {
    const res = await fetch('/collections');
    if (res.ok) collectionsCache = await res.json();
  } catch(e) {}
  const data = collectionsCache || {favorites: [], collections: {}};
  const colls = data.collections || {};
  const zims = zimsCache || [];

  let h = '';

  // Create collection form
  h += '<div class="manage-card"><h2>Collections</h2>';
  h += '<div class="coll-form">' +
    '<input id="coll-label" placeholder="Collection name (e.g. Dev Docs)" autocomplete="off">' +
    '<button onclick="createCollection()">Create</button>' +
  '</div>';

  // Each collection with inline ZIM picker
  for (const [name, coll] of Object.entries(colls)) {
    const label = coll.label || name;
    const collZims = coll.zims || [];
    const expanded = _expandedCollection === name;
    h += '<div class="coll-item" style="flex-direction:column;align-items:stretch" onclick="toggleCollExpand(\'' + escAttr(name) + '\')">';
    h += '<div style="display:flex;align-items:center;gap:12px">' +
      '<span class="coll-name" style="flex:1">' + esc(label) +
      ' <span style="font-weight:400;color:var(--text2);font-size:12px">(' + collZims.length + ' source' + (collZims.length !== 1 ? 's' : '') + ')</span></span>' +
      '<button class="coll-del" onclick="event.stopPropagation();deleteCollection(\'' + escAttr(name) + '\', this)" title="Delete collection">\u00D7</button>' +
    '</div>';
    if (expanded) {
      // Group ZIMs by category (same names + order as homepage)
      const catMap = {};
      for (const z of zims) {
        const cat = z.category || categorizeZim(z.name);
        if (!catMap[cat]) catMap[cat] = [];
        catMap[cat].push(z);
      }
      h += '<div style="margin-top:10px">';
      for (const cat of Object.keys(catMap).sort()) {
        const catZims = catMap[cat];
        h += '<div style="margin-bottom:8px"><div style="font-size:11px;color:var(--text2);margin-bottom:4px">' + esc(cat) + '</div>';
        h += '<div style="display:flex;flex-wrap:wrap;gap:6px">';
        for (const z of catZims) {
          const inColl = collZims.includes(z.name);
          const title = z.title || z.name;
          h += '<span class="pill' + (inColl ? ' active' : '') + '" style="cursor:pointer;font-size:12px" ' +
            'onclick="event.stopPropagation();toggleCollZim(\'' + escAttr(name) + '\',\'' + escAttr(z.name) + '\')">' +
            esc(title) + '</span>';
        }
        h += '</div></div>';
      }
      h += '</div>';
    }
    h += '</div>';
  }

  if (!Object.keys(colls).length) {
    h += '<div style="font-size:13px;color:var(--text2)">No collections yet. Create one to group ZIMs for scoped search.</div>';
  }
  h += '<div style="font-size:12px;color:var(--text2);margin-top:12px;opacity:0.7">Click a collection to add or remove sources. Collections with sources appear as homepage sections.</div>';
  h += '</div>';

  el.innerHTML = h;
}

async function createCollection() {
  const labelEl = document.getElementById('coll-label');
  if (!labelEl || !labelEl.value.trim()) return;
  const label = labelEl.value.trim();
  try {
    const opts = {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({label, zims: []})};
    if (_manageToken) opts.headers['Authorization'] = 'Bearer ' + _manageToken;
    const res = await fetch('/collections', opts);
    if (res.ok) {
      labelEl.value = '';
      renderCollectionsTab();
    }
  } catch(e) {}
}

async function deleteCollection(name, btn) {
  if (!btn) return;
  // Two-click confirmation
  if (!btn.classList.contains('confirming')) {
    btn.classList.add('confirming');
    btn.textContent = 'Delete?';
    btn.style.fontSize = '11px';
    btn.style.color = 'var(--amber)';
    setTimeout(() => { if (btn.classList.contains('confirming')) { btn.classList.remove('confirming'); btn.textContent = '\u00D7'; btn.style.fontSize = ''; btn.style.color = ''; }}, 3000);
    return;
  }
  try {
    const opts = {method: 'DELETE'};
    if (_manageToken) opts.headers = {'Authorization': 'Bearer ' + _manageToken};
    const res = await fetch('/collections?name=' + encodeURIComponent(name), opts);
    if (res.ok) renderCollectionsTab();
  } catch(e) {}
}

function toggleCollExpand(name) {
  _expandedCollection = _expandedCollection === name ? null : name;
  renderCollectionsTab();
}

async function toggleCollZim(collName, zimName) {
  const data = collectionsCache || {favorites: [], collections: {}};
  const coll = data.collections[collName];
  if (!coll) return;
  const zims = coll.zims || [];
  const idx = zims.indexOf(zimName);
  if (idx >= 0) zims.splice(idx, 1);
  else zims.push(zimName);
  try {
    const opts = {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({name: collName, label: coll.label || collName, zims})};
    if (_manageToken) opts.headers['Authorization'] = 'Bearer ' + _manageToken;
    const res = await fetch('/collections', opts);
    if (res.ok) {
      // Update cache in place so re-render is instant
      coll.zims = zims;
      renderCollectionsTab();
    }
  } catch(e) {}
}

// ── History tab ──
function _historyZimInfo(ev) {
  // Use cached ZIM info from event if available (survives deletion)
  if (ev.title) return { title: ev.title, name: ev.name || '', has_icon: ev.has_icon, _fromEvent: true };
  var filename = ev.filename;
  // Try to find ZIM in current library by filename
  if (zimsCache) {
    var z = zimsCache.find(function(z) { return z.file === filename; });
    if (z) return z;
  }
  // Strip date suffix to match by name prefix
  var name = (filename || '').replace(/\.zim$/, '').replace(/_\d{4}-\d{2}$/, '');
  if (zimsCache) {
    var z2 = zimsCache.find(function(z) { return z.name === name; });
    if (z2) return z2;
  }
  return null;
}

async function renderHistoryTab() {
  const el = document.getElementById('manage-history');
  if (!el) return;
  el.innerHTML = '<div class="loading"><span class="spinner-inline"></span>Loading...</div>';
  try {
    const res = await manageFetch('/manage/history');
    const data = await res.json();
    const events = data.history || [];
    if (!events.length) {
      el.innerHTML = '<div class="empty"><p>No history yet</p><p class="hint">Downloads, updates, and deletions will appear here</p></div>';
      return;
    }
    // Group by day
    const days = {};
    for (const ev of events) {
      const d = new Date(ev.ts * 1000);
      const key = d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
      if (!days[key]) days[key] = [];
      days[key].push(ev);
    }
    let h = '';
    for (const [day, evts] of Object.entries(days)) {
      h += '<div style="margin-bottom:20px">';
      h += '<div class="ci-section-label">' + esc(day) + '</div>';
      for (const ev of evts) {
        const time = new Date(ev.ts * 1000).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
        const zim = _historyZimInfo(ev);
        const title = zim ? (zim.title || zim.name) : (ev.filename || '').replace(/\.zim$/, '').replace(/_\d{4}-\d{2}$/, '').replace(/_/g, ' ');
        const canShowIcon = zim && zim.has_icon && zim.name && !zim._fromEvent;
        const iconHtml = canShowIcon
          ? '<img src="/w/' + encodeURIComponent(zim.name) + '/-/icon" width="40" height="40" loading="lazy">'
          : '<span class="ci-letter">' + (esc(title)[0] || '?').toUpperCase() + '</span>';

        let label = '', labelColor = 'var(--text2)';
        if (ev.event === 'updated') { label = 'Updated'; }
        else if (ev.event === 'download') { label = 'Downloaded'; }
        else if (ev.event === 'download_failed') { label = 'Failed'; labelColor = '#ef4444'; }
        else if (ev.event === 'deleted') { label = 'Deleted'; }
        else { label = ev.event; }

        const meta = [];
        if (ev.size_bytes) meta.push(fmtSize(ev.size_bytes / (1024 * 1024 * 1024)));
        meta.push(time);

        const clickable = zim && !zim._fromEvent;
        h += '<div class="catalog-item"' + (clickable ? ' style="cursor:pointer" onclick="enterSource(\'' + escJs(zim.name) + '\',true)"' : '') + '>' +
          '<div class="ci-icon">' + iconHtml + '</div>' +
          '<div class="ci-info">' +
            '<div class="ci-title">' + esc(title) + '</div>' +
            '<div class="ci-meta">' + meta.map(function(m){return '<span>'+m+'</span>'}).join(' &middot; ') + '</div>' +
          '</div>' +
          '<div class="ci-actions"><span style="font-size:12px;font-weight:600;color:' + labelColor + '">' + label + '</span></div>' +
        '</div>';
      }
      h += '</div>';
    }
    el.innerHTML = h;
  } catch(e) {
    el.innerHTML = '<div class="empty"><p>Could not load history</p></div>';
  }
}

// ── Stats tab (merged server stats + usage) ──
async function renderActivityTab() {
  const el = document.getElementById('manage-activity');
  if (!el) return;
  el.innerHTML = '<div class="loading"><span class="spinner-inline"></span>Loading...</div>';
  try {
    const [statsRes, usageRes] = await Promise.all([
      manageFetch('/manage/stats').catch(() => null),
      manageFetch('/manage/usage').catch(() => null)
    ]);
    let h = '';

    // Server stats section
    if (statsRes && statsRes.ok) {
      const sdata = await statsRes.json();
      const m = sdata.metrics || {};
      const d = sdata.disk || {};
      const au = sdata.auto_update || {};
      // Sync auto-update dropdown on Library Status card
      const freqSel = document.getElementById('auto-update-freq');
      if (freqSel) freqSel.value = au.enabled ? (au.frequency || 'weekly') : 'disabled';
      const uptime = m.uptime_seconds || 0;
      const uptimeStr = uptime < 3600 ? Math.round(uptime / 60) + 'm' :
                        uptime < 86400 ? Math.round(uptime / 3600) + 'h' :
                        Math.round(uptime / 86400) + 'd';
      h += '<div class="manage-card"><h2>Server</h2>';
      h += '<div class="mc-row"><span class="mc-label">Uptime</span><span class="mc-value">' + uptimeStr + '</span></div>';
      h += '<div class="mc-row"><span class="mc-label">Requests</span><span class="mc-value">' + (m.total_requests || 0) + '</span></div>';
      if (m.endpoints) {
        const eps = Object.entries(m.endpoints).sort((a,b) => b[1].count - a[1].count).slice(0, 5);
        for (const [ep, info] of eps) {
          h += '<div class="mc-row" style="padding-left:16px"><span class="mc-label" style="font-size:12px">' + esc(ep) + '</span><span class="mc-value" style="font-size:12px">' + info.count + ' (' + info.avg_latency_ms + 'ms avg)</span></div>';
        }
      }
      if (m.rate_limited) h += '<div class="mc-row"><span class="mc-label">Rate limited</span><span class="mc-value" style="color:var(--amber)">' + m.rate_limited + '</span></div>';
      if (d.zim_size_gb) h += '<div class="mc-row"><span class="mc-label">ZIM disk usage</span><span class="mc-value">' + d.zim_size_gb + ' GB</span></div>';
      if (d.disk_free_gb) h += '<div class="mc-row"><span class="mc-label">Disk free</span><span class="mc-value">' + d.disk_free_gb + ' GB</span></div>';
      h += '</div>';

      // Save title_index data for rendering after usage
      var _ti = sdata.title_index;
    }

    // Usage stats section (above title index — more relevant to user)
    if (usageRes && usageRes.ok) {
      const data = await usageRes.json();
      h += '<div class="manage-card"><h2>Usage</h2>';
      h += '<div class="mc-row"><span class="mc-label">Total searches</span><span class="mc-value">' + (data.searches || 0) + '</span></div>';
      h += '<div class="mc-row"><span class="mc-label">Total article reads</span><span class="mc-value">' + (data.article_reads || 0) + '</span></div>';
      h += '</div>';

      const topZims = data.top_zims || [];
      if (topZims.length > 0) {
        h += '<div class="manage-card"><h2>Top Sources</h2>';
        for (const z of topZims) {
          const info = zimsCache && zimsCache.find(x => x.name === z.name);
          const title = info ? (info.title || z.name) : z.name;
          const total = (z.reads || 0) + (z.searches || 0);
          h += '<div class="mc-row"><span class="mc-label">' + esc(title) + '</span>' +
            '<span class="mc-value">' + total + ' <span style="font-weight:400;color:var(--text2);font-size:11px">(' + pl(z.reads || 0, 'read') + ', ' + pl(z.searches || 0, 'search', 'searches') + ')</span></span></div>';
        }
        h += '</div>';
      }
    }

    // Search index card (compact, below usage)
    if (typeof _ti !== 'undefined' && _ti) {
      const ti = _ti;
      h += '<div class="manage-card"><h2>Search Index</h2>';

      // Building state
      if (ti.state === 'building') {
        h += '<div class="mc-row"><span class="mc-label">Building indexes</span><span class="mc-value" style="color:var(--amber)">' +
          (ti.building_now ? esc(ti.building_now) + '&hellip;' : 'In progress&hellip;') + '</span></div>';
      }

      // Categorize indexes — use ti.total (all ZIMs) as denominator, not just indexed ones
      const allIdx = ti.indexes || [];
      const withFts = allIdx.filter(x => x.has_fts && x.entries > 0);
      const noFts = allIdx.filter(x => !x.has_fts && x.entries > 0);
      const totalZims = ti.total || 0;
      const building = ti.state === 'building';

      // Title index row
      if (building) {
        h += '<div class="mc-row"><span class="mc-label">Title index</span><span class="mc-value" style="color:var(--amber)">' +
          ti.ready + ' of ' + totalZims +
          (ti.building_now ? ' <span style="font-size:11px">(' + esc(ti.building_now) + '&hellip;)</span>' : '') +
          '</span></div>';
      } else if (ti.ready < totalZims) {
        h += '<div class="mc-row"><span class="mc-label">Title index</span><span class="mc-value">' + ti.ready + ' of ' + totalZims + '</span></div>';
      } else {
        h += '<div class="mc-row"><span class="mc-label">Title index</span><span class="mc-value">' + pl(totalZims, 'source') + '</span></div>';
      }

      // Full-text index row — denominator is total ZIMs, not just ones with title indexes
      const ftsTotal = building ? totalZims : totalZims;  // always use total ZIMs
      if (withFts.length >= ftsTotal && ftsTotal > 0 && !building) {
        h += '<div class="mc-row"><span class="mc-label">Full-text index</span><span class="mc-value">' + pl(ftsTotal, 'source') + '</span></div>';
      } else {
        h += '<div class="mc-row"><span class="mc-label">Full-text index</span><span class="mc-value">' + withFts.length + ' of ' + ftsTotal + '</span></div>';
      }

      // Errors
      if (ti.errors && ti.errors.length > 0) {
        for (const [name, err] of ti.errors) {
          h += '<div class="mc-row"><span class="mc-label" style="color:var(--amber)">' + esc(name) + '</span><span class="mc-value" style="font-size:11px;color:var(--text2)">' + esc(err) + '</span></div>';
        }
      }

      // Sources without FTS — show build options (only when not actively building title indexes)
      if (noFts.length > 0 && !building) {
        h += '<div style="margin-top:8px;padding-top:8px;border-top:1px solid var(--border)">';
        h += '<div style="font-size:12px;color:var(--text2);margin-bottom:4px">Full-text finds results <em>inside</em> articles, not just titles:</div>';
        for (const idx of noFts) {
          const sizeLbl = idx.size_mb >= 1024 ? (idx.size_mb / 1024).toFixed(1) + ' GB' : Math.round(idx.size_mb) + ' MB';
          const estMin = Math.max(1, Math.ceil(idx.size_mb / 500));
          const timeLbl = estMin >= 60 ? Math.round(estMin / 60) + ' hr' : '~' + estMin + ' min';
          h += '<div class="mc-row" style="align-items:center"><span class="mc-label">' + esc(idx.name) + ' <span style="color:var(--text2);font-weight:400">(' + sizeLbl + ', ' + timeLbl + ')</span></span>';
          h += '<button onclick="buildFts(\'' + escAttr(idx.name) + '\', this)" style="font-size:11px;padding:3px 10px;cursor:pointer;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--text1);white-space:nowrap">Build</button>';
          h += '</div>';
        }
        h += '</div>';
      }
      h += '</div>';
    }

    if (!h) {
      h = '<div style="text-align:center;padding:24px;color:var(--text2);font-size:13px">No stats available yet. Start searching and reading articles!</div>';
    }
    h += '<div style="text-align:center;padding:8px;font-size:11px;color:var(--text2);opacity:0.5">Stats are in-memory only and reset when the server restarts.</div>';
    el.innerHTML = h;
  } catch(e) {
    el.innerHTML = '<div class="empty"><p>Could not load stats</p></div>';
  }
}

// ── Manage entry point ──
async function renderManage() {
  // If password is set, prompt before showing manage (use saved token only if "remember me" was used)
  const hasPw = await fetch('/manage/has-password').then(r => r.json()).catch(() => ({}));
  if (hasPw.has_password) {
    const saved = sessionStorage.getItem('zimi_manage_pw');
    if (saved) {
      _manageToken = saved;
    } else if (!_manageToken) {
      try {
        await new Promise(function(resolve, reject) {
          _pwResolve = function(token) {
            _manageToken = token;
            if (document.getElementById('pw-remember').checked) {
              sessionStorage.setItem('zimi_manage_pw', token);
            }
            _pwReject = null;
            resolve();
          };
          _pwReject = reject;
          openPwModal('Enter password');
        });
      } catch(e) {
        // Cancelled — go back to home
        toggleManage(); return;
      }
    }
  }
  const installedCount = zimsCache ? zimsCache.length : 0;

  output.innerHTML =
    '<div id="manage-status" class="manage-card"><h2>Library Status</h2><div class="loading"><span class="spinner-inline"></span>Loading...</div></div>' +
    '<div id="manage-downloads"></div>' +
    '<div class="manage-tabs">' +
      '<button class="manage-tab' + (manageTab === 'installed' ? ' active' : '') + '" data-tab="installed" onclick="switchManageTab(\'installed\')">Installed</button>' +
      '<button class="manage-tab' + (manageTab === 'browse' ? ' active' : '') + '" data-tab="browse" onclick="switchManageTab(\'browse\')">Catalog</button>' +
      '<button class="manage-tab' + (manageTab === 'collections' ? ' active' : '') + '" data-tab="collections" onclick="switchManageTab(\'collections\')">Collections</button>' +
      '<button class="manage-tab' + (manageTab === 'history' ? ' active' : '') + '" data-tab="history" onclick="switchManageTab(\'history\')">History</button>' +
      '<button class="manage-tab' + (manageTab === 'activity' ? ' active' : '') + '" data-tab="activity" onclick="switchManageTab(\'activity\')">Stats</button>' +
    '</div>' +
    '<div id="manage-installed" class="manage-tab-content' + (manageTab === 'installed' ? ' active' : '') + '"></div>' +
    '<div id="manage-collections" class="manage-tab-content' + (manageTab === 'collections' ? ' active' : '') + '"></div>' +
    '<div id="manage-history" class="manage-tab-content' + (manageTab === 'history' ? ' active' : '') + '"></div>' +
    '<div id="manage-activity" class="manage-tab-content' + (manageTab === 'activity' ? ' active' : '') + '"></div>' +
    '<div id="manage-browse" class="manage-tab-content' + (manageTab === 'browse' ? ' active' : '') + '">' +
      '<div class="manage-search" style="justify-content:flex-end">' +
        '<select id="catalog-lang" onchange="_catalogCache=null;if(_browseView===\'drilldown\'&&manageCategoryFilter)drillCategory(manageCategoryFilter);else if(_browseView===\'search\'){var v=q.value.trim();if(v)browseCatalogFilter(v);else renderBrowseGallery();}else renderBrowseGallery();" style="max-width:140px"><option value="">All languages</option><option value="eng">English</option><option value="fra">French</option><option value="deu">German</option><option value="spa">Spanish</option></select>' +
      '</div>' +
      '<div id="catalog-results"></div>' +
    '</div>';

  // Status card
  try {
    const res = await manageFetch('/manage/status');
    const data = await res.json();
    const statusEl = document.getElementById('manage-status');
    if (statusEl) {
      statusEl.innerHTML = '<h2>Library Status</h2>' +
        '<div class="mc-row"><span class="mc-label">ZIM files</span><span class="mc-value">' + esc(String(data.zim_count)) + '</span></div>' +
        '<div class="mc-row"><span class="mc-label">Total size</span><span class="mc-value">' + fmtSize(data.total_size_gb) + '</span></div>' +
        '<div id="update-status" class="mc-row"><span class="mc-label">Updates</span><span class="mc-value" style="color:var(--text2)">Checking\u2026</span></div>' +
        '<div class="mc-row" style="align-items:center">' +
          '<span class="mc-label">Auto-update</span>' +
          '<span class="mc-value">' +
            '<select id="auto-update-freq" onchange="toggleAutoUpdate()" style="font-size:12px;padding:3px 8px;border-radius:4px;border:1px solid var(--border);background:var(--surface2);color:var(--text)">' +
              '<option value="disabled">Disabled</option><option value="daily">Daily</option><option value="weekly">Weekly</option><option value="monthly">Monthly</option>' +
            '</select>' +
          '</span>' +
        '</div>' +
        '<div style="margin-top:12px;display:flex;gap:8px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap">' +
          '<div style="display:flex;gap:8px;flex-wrap:wrap">' +
            (IS_DESKTOP
              ? '<button class="manage-btn-action" onclick="openSettings()" style="background:var(--surface2);color:var(--text);border:1px solid var(--border)">&#x2699;&#xFE0F; Settings</button>'
              : '<button id="refresh-cache-btn" class="manage-btn-action" onclick="settingsRefreshCache()" style="background:var(--surface2);color:var(--text);border:1px solid var(--border)">&#x1F504; Refresh Cache</button>') +
            '<button id="pw-btn" class="manage-btn-action" onclick="managePassword()" style="background:var(--surface2);color:var(--text);border:1px solid var(--border)">&#x1F512; Password</button>' +
            (sessionStorage.getItem('zimi_manage_pw') ? '<button class="manage-btn-action" onclick="manageLogout()" style="background:var(--surface2);color:var(--text);border:1px solid var(--border)" title="Log out and return to home">Log Out</button>' : '') +
          '</div>' +
          '<button id="update-all-btn" class="manage-btn-action" onclick="triggerUpdate()" style="display:none">Update All</button>' +
        '</div>' +
        '';
    }
    // Sync auto-update dropdown from server
    const au = data.auto_update || {};
    const freqSel = document.getElementById('auto-update-freq');
    if (freqSel) {
      freqSel.value = au.enabled ? (au.frequency || 'weekly') : 'disabled';
      if (au.locked) {
        freqSel.disabled = true;
        freqSel.title = 'Controlled by ZIMI_AUTO_UPDATE env var';
        freqSel.style.opacity = '0.5';
      }
    }
    checkForUpdates();
  } catch(e) {
    const statusEl = document.getElementById('manage-status');
    if (statusEl) statusEl.innerHTML = '<h2>Library Status</h2><div style="color:var(--text2);font-size:13px">Could not load library status</div>';
  }

  // Render active tab content
  pillsBar.innerHTML = ''; pillsBar.style.display = 'none';
  if (manageTab === 'installed') {
    renderInstalled();
  } else if (manageTab === 'collections') {
    renderCollectionsTab();
  } else if (manageTab === 'history') {
    renderHistoryTab();
  } else if (manageTab === 'activity') {
    renderActivityTab();
  } else {
    if (manageCategoryFilter) {
      drillCategory(manageCategoryFilter);
    } else {
      renderBrowseGallery();
    }
  }
  refreshDownloads();
  // Pre-fetch catalog in background so flavor pills can populate on installed tab
  if (!_catalogCache) {
    loadFullCatalog().then(() => { if (manageTab === 'installed') renderInstalled(); }).catch(() => {});
  }
}


async function toggleAutoUpdate() {
  const freqSel = document.getElementById('auto-update-freq');
  if (!freqSel) return;
  const val = freqSel.value;
  const enabled = val !== 'disabled';
  try {
    await manageFetch('/manage/auto-update', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({enabled: enabled, frequency: enabled ? val : 'weekly'})
    });
  } catch(e) { /* ignore */ }
  // Update timer icon next to update count
  const el = document.getElementById('update-status');
  if (el) {
    const timer = _autoUpdateTimerHtml();
    const count = Object.keys(_availableUpdates).length;
    if (count > 0) {
      el.innerHTML = '<span class="mc-label">Updates</span><span class="mc-value" style="color:var(--amber)">' + timer + count + ' available</span>';
    } else {
      el.innerHTML = '<span class="mc-label">Updates</span><span class="mc-value" style="color:var(--text2)">All up to date</span>';
    }
  }
  // Start fast polling when enabling — server starts downloads after 30s delay
  if (enabled && !_dlTimer) {
    _dlTimer = setTimeout(refreshDownloads, 3000);
  }
}

function getInstalledPillsHtml() {
  const zims = zimsCache || [];
  const cats = new Set();
  for (const z of zims) cats.add(z.category || categorizeZim(z.name));
  const sorted = [...cats].sort();
  if (sorted.length <= 1) return '';
  let h = '<div class="pills" style="margin-bottom:12px"><button class="pill' + (!manageCategoryFilter ? ' active' : '') + '" onclick="filterManageCategory(null)">All</button>';
  for (const cat of sorted) {
    h += '<button class="pill' + (manageCategoryFilter === cat ? ' active' : '') + '" onclick="filterManageCategory(\'' + escAttr(cat) + '\')">' + esc(cat) + '</button>';
  }
  return h + '</div>';
}

function filterManageCategory(cat) {
  manageCategoryFilter = cat;
  renderInstalled();
}

// ── Installed tab: render ZIMs grouped by category ──
function renderInstalled(filterText) {
  const el = document.getElementById('manage-installed');
  if (!el) return;
  const zims = zimsCache || [];
  if (!zims.length) {
    el.innerHTML = '<div class="empty"><p>No ZIMs installed</p><div class="hint">Switch to Catalog to add some</div></div>';
    return;
  }

  // Group by category
  const groups = {};
  for (const z of zims) {
    const cat = z.category || categorizeZim(z.name);
    if (manageCategoryFilter && cat !== manageCategoryFilter) continue;
    if (filterText) {
      const ft = filterText.toLowerCase();
      const title = (z.title || z.name).toLowerCase();
      const catLower = cat.toLowerCase();
      if (!title.includes(ft) && !catLower.includes(ft) && !z.name.toLowerCase().includes(ft)) continue;
    }
    if (!groups[cat]) groups[cat] = [];
    groups[cat].push(z);
  }

  // Merge small categories (<3 items) into Other
  const MIN_CAT = 3;
  for (const cat of Object.keys(groups)) {
    if (cat !== 'Other' && groups[cat].length < MIN_CAT) {
      if (!groups['Other']) groups['Other'] = [];
      groups['Other'].push(...groups[cat]);
      delete groups[cat];
    }
  }

  const catOrder = Object.keys(groups).sort();
  let items_h = '';
  for (const cat of catOrder) {
    const items = groups[cat];
    if (!items || !items.length) continue;
    items.sort((a, b) => (a.title || a.name).localeCompare(b.title || b.name));
    items_h += '<div class="manage-installed-group">';
    items_h += '<div class="ci-section-label">' + esc(cat) + ' (' + items.length + ')</div>';
    for (const z of items) {
      const iconHtml = z.has_icon
        ? '<img src="/w/' + encodeURIComponent(z.name) + '/-/icon" width="40" height="40" loading="lazy">'
        : '<span class="ci-letter">' + (esc(z.title || z.name)[0] || '?').toUpperCase() + '</span>';
      const meta = [];
      if (typeof z.entries === 'number') meta.push(z.entries.toLocaleString() + ' entries');
      meta.push(fmtSize(z.size_gb));
      // Show date from ZIM metadata
      const dateStr = z.date ? z.date.substring(0, 7) : null;
      if (dateStr) meta.push(dateStr);
      // Flavor badge from filename
      const flavor = variantLabel(z.file);
      const variants = getFlavorVariants(z);
      // Show flavor as clickable pill if alternatives exist, otherwise just text
      if (variants.length > 1) {
        const vid = '_fv_' + z.name;
        window[vid] = variants;
        meta.push('<span class="flavor-pill" onclick="showFlavorPicker(this, \'' + escAttr(z.name) + '\', window[\'' + vid + '\'])">' +
          esc(flavor || 'Full') +
          '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>' +
        '</span>');
      } else if (flavor) {
        meta.push('<span style="color:var(--text2);font-weight:500">' + esc(flavor) + '</span>');
      }
      const upd = _availableUpdates[z.file];
      let actionsHtml = '';
      if (upd) {
        actionsHtml = '<button class="ci-update-btn" onclick="downloadZim(\'' + escAttr(upd.download_url) + '\', this, true)" title="From ' + escAttr(dateStr || '?') + ' \u2192 ' + escAttr(upd.latest_date) + '">' +
          'Update</button>' +
          '<button class="ci-delete-btn" onclick="deleteZim(\'' + escAttr(z.file) + '\', this)" title="Delete">\u00D7</button>';
      } else {
        actionsHtml = '<span class="ci-installed-badge">Installed</span>' +
          '<button class="ci-delete-btn" onclick="deleteZim(\'' + escAttr(z.file) + '\', this)" title="Delete">\u00D7</button>';
      }
      items_h += '<div class="catalog-item' + (upd ? ' ci-has-update' : '') + '" style="cursor:pointer" onclick="if(!event.target.closest(\'button\')){enterSource(\'' + escJs(z.name) + '\',true)}">' +
        '<div class="ci-icon">' + iconHtml + '</div>' +
        '<div class="ci-info">' +
          '<div class="ci-title">' + esc(z.title || z.name) + '</div>' +
          '<div class="ci-meta">' + meta.map(function(m){return '<span>'+m+'</span>'}).join(' &middot; ') + '</div>' +
        '</div>' +
        '<div class="ci-actions">' + actionsHtml + '</div>' +
      '</div>';
    }
    items_h += '</div>';
  }
  if (!items_h) items_h = '<div class="empty"><p>No matching ZIMs</p></div>';
  el.innerHTML = getInstalledPillsHtml() + items_h;
}

function getFlavorVariants(installedZim) {
  // Find catalog variants that match this installed ZIM's base name
  if (!_catalogCache) return [];
  // Installed file: "wikipedia_en_all_maxi_2024-05.zim" → base: "wikipedia_en_all"
  const fb = (installedZim.file || '').replace(/\.zim$/, '');
  const matching = _catalogCache.filter(item => {
    return fb.startsWith(item.name + '_') || fb === item.name;
  });
  if (matching.length <= 1) return [];
  // Build variant list with labels and sizes
  const currentFlavor = variantLabel(installedZim.file) || 'Full';
  return matching.map(item => ({
    label: variantLabel(item.download_url) || 'Full',
    size: formatSize(item.size_bytes),
    url: item.download_url,
    current: (variantLabel(item.download_url) || 'Full') === currentFlavor,
  })).sort((a, b) => {
    const order = { 'Mini': 0, 'No images': 1, 'Full': 2 };
    return (order[a.label] ?? 1) - (order[b.label] ?? 1);
  });
}

function showFlavorPicker(btn, zimName, variants) {
  // Remove any existing popup
  closeFlavorPopup();
  const rect = btn.getBoundingClientRect();
  // Backdrop to close on click outside
  const backdrop = document.createElement('div');
  backdrop.className = 'flavor-popup-backdrop';
  backdrop.onclick = closeFlavorPopup;
  document.body.appendChild(backdrop);
  // Popup
  const popup = document.createElement('div');
  popup.className = 'flavor-popup';
  popup.id = 'flavor-popup';
  let html = '<div class="flavor-popup-title">Choose flavor</div>';
  for (const v of variants) {
    if (v.current) {
      html += '<div class="flavor-option current">' +
        '<div class="flavor-option-check">\u2713</div>' +
        '<div class="flavor-option-label">' + esc(v.label) + '</div>' +
        '<div class="flavor-option-size">' + esc(v.size) + '</div>' +
      '</div>';
    } else {
      html += '<div class="flavor-option" onclick="switchToFlavor(\'' + escAttr(v.url) + '\', \'' + escAttr(zimName) + '\')">' +
        '<div class="flavor-option-check"></div>' +
        '<div class="flavor-option-label">' + esc(v.label) + '</div>' +
        '<div class="flavor-option-size">' + esc(v.size) + '</div>' +
      '</div>';
    }
  }
  popup.innerHTML = html;
  document.body.appendChild(popup);
  // Position below the pill, clamped to viewport
  const popW = popup.offsetWidth;
  let left = rect.right - popW;
  if (left < 8) left = 8;
  let top = rect.bottom + 6;
  if (top + popup.offsetHeight > window.innerHeight - 8) top = rect.top - popup.offsetHeight - 6;
  popup.style.left = left + 'px';
  popup.style.top = top + 'px';
}

function closeFlavorPopup() {
  const popup = document.getElementById('flavor-popup');
  if (popup) popup.remove();
  const bd = document.querySelector('.flavor-popup-backdrop');
  if (bd) bd.remove();
}

function switchToFlavor(downloadUrl, zimName) {
  closeFlavorPopup();
  // Find a nearby download button or create a virtual one for progress tracking
  downloadZim(downloadUrl, null);
}

// ── Browse tab: explicit server-side catalog search (Search button / Enter in catalog-q) ──
async function searchCatalog(append) {
  const cq = document.getElementById('catalog-q');
  const lang = document.getElementById('catalog-lang');
  const results = document.getElementById('catalog-results');
  if (!results) return;
  _browseView = 'search';
  if (!append) {
    catalogPage = 0;
    results.innerHTML = '<div class="loading"><span class="spinner-inline"></span>Searching Kiwix catalog...</div>';
    if (_catalogObserver) { _catalogObserver.disconnect(); _catalogObserver = null; }
  }

  const query = (cq && cq.value) || '';
  const langVal = (lang && lang.value) || 'eng';
  const pageSize = 50;
  try {
    const res = await manageFetch('/manage/catalog?q=' + encodeURIComponent(query) + '&lang=' + encodeURIComponent(langVal) + '&count=' + pageSize + '&start=' + (catalogPage * pageSize));
    const data = await res.json();
    if (data.error) { results.innerHTML = '<div class="empty"><p>' + esc(data.error) + '</p></div>'; return; }
    _catalogTotal = data.total || 0;

    const grouped = groupVariants(data.items || []);
    let h = '';
    if (!append) {
      h += '<div class="browse-drilldown-header">' +
        '<button class="browse-back" onclick="renderBrowseGallery();q.value=\'\'">\u2190 Catalog</button>' +
        '<span class="browse-drilldown-count">' + esc(String(data.total)) + ' results</span></div>';
    }
    for (const item of grouped) h += renderCatalogItem(item);

    // Remove old sentinel
    const oldSentinel = results.querySelector('#catalog-sentinel');
    if (oldSentinel) oldSentinel.remove();

    if (append) {
      results.insertAdjacentHTML('beforeend', h);
    } else {
      results.innerHTML = h;
    }

    // Infinite scroll sentinel
    const loaded = (catalogPage + 1) * pageSize;
    if (loaded < _catalogTotal) {
      const sentinel = document.createElement('div');
      sentinel.id = 'catalog-sentinel';
      sentinel.innerHTML = '<div class="loading" style="padding:16px"><span class="spinner-inline"></span>Loading more...</div>';
      results.appendChild(sentinel);
      _catalogObserver = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) {
          _catalogObserver.disconnect();
          catalogPage++;
          searchCatalog(true);
        }
      }, { rootMargin: '200px' });
      _catalogObserver.observe(sentinel);
    }
  } catch(e) {
    if (!append) results.innerHTML = '<div class="empty"><p>Failed to fetch catalog</p></div>';
  }
}

async function downloadZim(url, btn, isUpdate) {
  if (btn) { btn.disabled = true; btn.textContent = 'Starting...'; }
  try {
    const res = await manageFetch('/manage/download', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url }),
    });
    const data = await res.json();
    if (data.error) { if (btn) { btn.textContent = 'Error'; btn.disabled = false; } return; }
    if (btn) {
      btn.dataset.dlUrl = url;
      btn.textContent = isUpdate ? 'Updating...' : 'Downloading...';
      btn.classList.add('ci-installed-badge');
      // Hide sibling variant buttons (only show the one being downloaded)
      const variantsDiv = btn.closest('.ci-variants');
      if (variantsDiv) {
        variantsDiv.querySelectorAll('.ci-variant-btn').forEach(b => {
          if (b !== btn) b.style.display = 'none';
        });
      }
    }
    _dlPrevAllDone = false; // ensure completion transition fires even for fast downloads
    refreshDownloads();
  } catch(e) {
    if (btn) { btn.textContent = 'Error'; btn.disabled = false; }
  }
}

async function deleteZim(filename, btn) {
  if (!btn) return;
  // Two-click confirmation: first click shows "Delete?", second click confirms
  if (!btn.classList.contains('confirming')) {
    btn.classList.add('confirming');
    btn.textContent = 'Delete?';
    btn.title = 'Click again to confirm deletion';
    // Reset after 4 seconds if not confirmed
    setTimeout(() => { if (btn.classList.contains('confirming')) { btn.classList.remove('confirming'); btn.textContent = '\u00D7'; btn.title = 'Delete this ZIM file'; }}, 4000);
    return;
  }
  btn.textContent = 'Deleting...';
  btn.disabled = true;
  const card = btn.closest('.catalog-item');
  // Optimistic: hide card immediately
  if (card) card.style.display = 'none';
  zimsCache = (zimsCache || []).filter(function(z) { return z.file !== filename; });
  try {
    const res = await manageFetch('/manage/delete', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({filename})
    });
    const data = await res.json();
    if (data.error) {
      // Restore card on error
      if (card) card.style.display = '';
      btn.textContent = 'Error: ' + data.error;
      return;
    }
    if (card) card.remove();
    _catalogCache = null;
    const lr = await fetch('/list');
    zimsCache = await lr.json();
    const tabBtn = document.querySelector('.manage-tab[data-tab="installed"]');
    if (tabBtn) tabBtn.textContent = 'Installed';
    // Update status card count
    const statusVal = document.querySelector('#manage-status .mc-value');
    if (statusVal) statusVal.textContent = String(zimsCache ? zimsCache.length : 0);
    // Re-render current view
    if (manageTab === 'installed') renderInstalled();
    else if (_browseView === 'drilldown' && manageCategoryFilter) drillCategory(manageCategoryFilter);
    else if (_browseView === 'gallery') renderBrowseGallery();
  } catch(e) {
    btn.textContent = 'Error';
  }
}

async function buildFts(name, btn) {
  if (!btn) return;
  btn.textContent = 'Building...';
  btn.disabled = true;
  try {
    const res = await manageFetch('/manage/build-fts', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({name})
    });
    const data = await res.json();
    if (data.error) { btn.textContent = 'Error'; btn.title = data.error; return; }
    if (data.status === 'already_exists') { btn.textContent = 'Already built'; return; }
    btn.textContent = 'Done (' + (data.elapsed || '?') + 's)';
  } catch(e) {
    btn.textContent = 'Error';
  }
}

function dlTitle(dl) {
  const fn = dl.filename || '';
  const url = dl.url || '';
  const urlBase = url.replace(/\.meta4$/, '');
  // 1. Exact match in installed cache
  if (zimsCache) {
    const exact = zimsCache.find(z => z.file === fn);
    if (exact) return exact.title || exact.name;
  }
  // 2. Check _availableUpdates — match by download URL
  for (const u of Object.values(_availableUpdates)) {
    if (u.download_url && url) {
      const uBase = u.download_url.replace(/\.meta4$/, '');
      if (uBase === urlBase) return u.title || u.name;
    }
  }
  // 3. Base-name match in installed cache (for updates: same ZIM, different date)
  const base = fn.replace(/\.zim$/, '').replace(/_\d{4}-\d{2}$/, '');
  if (zimsCache && base) {
    const match = zimsCache.find(z => {
      const zbase = (z.file || '').replace(/\.zim$/, '').replace(/_\d{4}-\d{2}$/, '');
      return zbase === base;
    });
    if (match) return match.title || match.name;
  }
  // 4. Catalog cache match by download URL
  if (_catalogCache && url) {
    for (const item of _catalogCache) {
      if (item.download_url) {
        const iBase = item.download_url.replace(/\.meta4$/, '');
        if (iBase === urlBase) return item.title || item.name;
      }
    }
  }
  // 5. Humanize filename
  return base.replace(/_/g, ' ').replace(/\./g, ' ');
}

let _dlPrevAllDone = true; // track when downloads finish to trigger refresh

async function refreshDownloads() {
  if (_dlTimer) { clearTimeout(_dlTimer); _dlTimer = null; }
  const dlEl = document.getElementById('manage-downloads');
  if (!dlEl) return;
  try {
    const res = await manageFetch('/manage/downloads');
    const data = await res.json();
    const dls = data.downloads || [];
    if (!dls.length) {
      dlEl.innerHTML = ''; _dlPrevAllDone = true;
      // Keep polling if auto-update may start downloads
      const sel = document.getElementById('auto-update-freq');
      if (sel && sel.value !== 'disabled' && mode === 'manage') _dlTimer = setTimeout(refreshDownloads, 5000);
      return;
    }
    const anyActive = dls.some(d => !d.done);
    const allDone = !anyActive;
    let h = '<div class="manage-card"><h2>Downloads</h2>';
    if (allDone) {
      h += '<button class="dl-clear-btn" onclick="clearDownloads()">Clear</button>';
    }
    for (const dl of dls) {
      const title = dlTitle(dl);
      const fmtBytes = (b) => { const gb = b / (1024 ** 3); return gb >= 1 ? gb.toFixed(1) + ' GB' : (b / (1024 ** 2)).toFixed(0) + ' MB'; };
      const totalStr = dl.total_bytes ? fmtBytes(dl.total_bytes) : '?';
      const dlStr = fmtBytes(dl.downloaded_bytes);
      const pct = dl.percent || 0;
      const speed = dl.elapsed > 0 && dl.downloaded_bytes > 0 ? ((dl.downloaded_bytes / 1024 / 1024) / dl.elapsed).toFixed(1) : '0';

      h += '<div class="dl-item">';
      h += '<div class="dl-row"><span class="dl-name">' + esc(title) + '</span>';
      if (dl.error) {
        h += '<span class="dl-error">' + esc(dl.error) + '</span>';
      } else if (dl.done) {
        h += '<span class="dl-done">\u2713 Complete</span>';
      } else {
        h += '<span class="dl-size">' + dlStr + ' / ' + totalStr + ' · ' + speed + ' MB/s</span>';
      }
      h += '</div>';

      if (!dl.done && !dl.error) {
        h += '<div class="dl-progress"><div class="dl-progress-bar" style="width:' + pct + '%"></div></div>';
        h += '<div class="dl-actions"><button class="dl-cancel-btn" onclick="cancelDownload(\'' + escAttr(dl.id) + '\')">Cancel</button></div>';
      }
      if (dl.error && dl.error !== 'Cancelled') {
        h += '<div class="dl-actions"><button class="dl-retry-btn" onclick="downloadZim(\'' + escAttr(dl.url) + '\')">Retry</button></div>';
      }
      h += '</div>';
    }
    h += '</div>';
    dlEl.innerHTML = h;
    // Update catalog item buttons with download progress
    for (const dl of dls) {
      const btns = document.querySelectorAll('[data-dl-url]');
      for (const btn of btns) {
        if (btn.dataset.dlUrl === dl.url || btn.dataset.dlUrl.replace(/\.meta4$/, '') === dl.url) {
          if (dl.done) { btn.textContent = 'Installed'; }
          else if (dl.error) { btn.textContent = 'Error'; btn.disabled = false; }
          else if (dl.percent > 0) { btn.textContent = Math.round(dl.percent) + '%'; }
          else { btn.textContent = dl.is_update ? 'Updating...' : 'Downloading...'; }
        }
      }
    }
    // Track update progress — count downloads still in-flight
    if (Object.keys(_availableUpdates).length > 0) {
      const doneUrls = new Set();
      for (const dl of dls) {
        if (dl.done) doneUrls.add(dl.url);
      }
      // Count updates whose download is still active (not done)
      let stillRunning = 0;
      for (const u of Object.values(_availableUpdates)) {
        if (!u.download_url) continue;
        const stripped = u.download_url.replace(/\.meta4$/, '');
        if (!doneUrls.has(stripped) && !doneUrls.has(u.download_url)) stillRunning++;
      }
      const anyActive = dls.some(d => !d.done);
      const updateEl = document.getElementById('update-status');
      const updateBtn = document.getElementById('update-all-btn');
      if (anyActive) {
        // Downloads still running — show how many remain
        if (updateEl) updateEl.innerHTML = '<span class="mc-label">Updates</span><span class="mc-value" style="color:var(--amber)">' + stillRunning + ' remaining</span>';
        if (updateBtn && updateBtn.disabled) updateBtn.textContent = 'Updating ' + stillRunning + '...';
      } else {
        // All downloads finished — remove successful ones from _availableUpdates
        for (const dl of dls) {
          if (dl.done && !dl.error) {
            for (const [key, u] of Object.entries(_availableUpdates)) {
              const stripped = (u.download_url || '').replace(/\.meta4$/, '');
              if (dl.url === stripped || dl.url === u.download_url) { delete _availableUpdates[key]; break; }
            }
          }
        }
        const remaining = Object.keys(_availableUpdates).length;
        const timer = _autoUpdateTimerHtml();
        if (remaining > 0) {
          if (updateEl) updateEl.innerHTML = '<span class="mc-label">Updates</span><span class="mc-value" style="color:var(--amber)">' + timer + remaining + ' available</span>';
          if (updateBtn) { updateBtn.style.display = ''; updateBtn.disabled = false; updateBtn.textContent = 'Update All'; }
        } else {
          if (updateEl) updateEl.innerHTML = '<span class="mc-label">Updates</span><span class="mc-value" style="color:var(--text2)">All up to date</span>';
          if (updateBtn) updateBtn.style.display = 'none';
        }
      }
    }
    // When downloads finish, refresh the library to show new installs
    if (allDone && !_dlPrevAllDone) {
      _dlPrevAllDone = true;
      try {
        const lr = await fetch('/list');
        zimsCache = await lr.json();
        _catalogCache = null;
        _availableUpdates = {};
        await loadFullCatalog().catch(() => {});
        renderInstalled();
        checkForUpdates();
        // Update status card counts
        const sr = await manageFetch('/manage/status');
        const sd = await sr.json();
        const statusEl = document.getElementById('manage-status');
        if (statusEl) {
          statusEl.querySelector('.mc-value').textContent = String(sd.zim_count);
          const sizeVal = statusEl.querySelectorAll('.mc-value')[1];
          if (sizeVal) sizeVal.textContent = fmtSize(sd.total_size_gb);
        }
        // Update tab count
        const tabBtn = document.querySelector('.manage-tab[data-tab="installed"]');
        if (tabBtn) tabBtn.textContent = 'Installed';
        // Re-render browse view so download buttons update to "Installed"
        if (manageTab === 'browse') {
          if (_browseView === 'drilldown' && manageCategoryFilter) drillCategory(manageCategoryFilter);
          else if (_browseView === 'search') { var sq = q.value.trim(); if (sq) browseCatalogFilter(sq); }
          else if (_browseView === 'gallery') renderBrowseGallery();
        }
      } catch(e) {}
    }
    _dlPrevAllDone = allDone;
    if (mode === 'manage') {
      // Poll fast while downloads active, slow-poll when auto-update enabled (server may start downloads)
      const sel = document.getElementById('auto-update-freq');
      const autoOn = sel && sel.value !== 'disabled';
      if (anyActive) _dlTimer = setTimeout(refreshDownloads, 2000);
      else if (autoOn) _dlTimer = setTimeout(refreshDownloads, 10000);
    }
  } catch(e) {}
}

async function clearDownloads() {
  try {
    await manageFetch('/manage/clear-downloads', { method: 'POST' });
    const dlEl = document.getElementById('manage-downloads');
    if (dlEl) dlEl.innerHTML = '';
  } catch(e) {}
}

async function cancelDownload(id) {
  try {
    await manageFetch('/manage/cancel', {
      method: 'POST', headers: {'Content-Type': 'application/json'},
      body: JSON.stringify({id})
    });
    refreshDownloads();
  } catch(e) {}
}

async function triggerUpdate() {
  const updates = Object.values(_availableUpdates);
  if (!updates.length) return;
  const btn = document.getElementById('update-all-btn');
  if (btn) { btn.disabled = true; btn.textContent = 'Updating ' + updates.length + '...'; }
  // Mark individual Update buttons so refreshDownloads() can show per-row progress
  for (const u of updates) {
    document.querySelectorAll('.ci-update-btn').forEach(b => {
      if (b.getAttribute('onclick') && b.getAttribute('onclick').includes(u.download_url)) {
        b.dataset.dlUrl = u.download_url;
        b.disabled = true;
        b.textContent = 'Queued...';
      }
    });
  }
  let started = 0;
  for (const u of updates) {
    try {
      const res = await manageFetch('/manage/download', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({url: u.download_url}) });
      const data = await res.json();
      if (data.id) started++;
    } catch(e) {}
  }
  // Don't reset button — refreshDownloads() will track progress and update it
  if (started > 0) { _dlPrevAllDone = false; refreshDownloads(); }
  else if (btn) { btn.disabled = false; btn.textContent = 'Update All'; }
}

function _autoUpdateTimerHtml() {
  const sel = document.getElementById('auto-update-freq');
  if (!sel || sel.value === 'disabled') return '';
  return '<svg title="Auto-update ' + sel.value + '" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;opacity:0.5;margin-right:5px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
}

async function checkForUpdates() {
  const el = document.getElementById('update-status');
  if (!el) return;
  try {
    const res = await manageFetch('/manage/check-updates');
    const data = await res.json();
    _availableUpdates = {};
    if (data.updates) {
      for (const u of data.updates) {
        if (u.installed_file) _availableUpdates[u.installed_file] = u;
      }
    }
    const count = Object.keys(_availableUpdates).length;
    const updateBtn = document.getElementById('update-all-btn');
    const timer = _autoUpdateTimerHtml();
    if (count > 0) {
      el.innerHTML = '<span class="mc-label">Updates</span><span class="mc-value" style="color:var(--amber)">' + timer + count + ' available</span>';
      if (updateBtn) { updateBtn.style.display = ''; updateBtn.textContent = 'Update All'; }
    } else {
      el.innerHTML = '<span class="mc-label">Updates</span><span class="mc-value" style="color:var(--text2)">All up to date</span>';
      if (updateBtn) updateBtn.style.display = 'none';
    }
    // Re-render installed tab to show inline update buttons
    if (manageTab === 'installed') renderInstalled();
  } catch(e) {
    // Silently fail — update check is optional
  }
}

async function refreshLibrary() {
  try {
    const res = await manageFetch('/manage/refresh', { method: 'POST' });
    const data = await res.json();
    if (data.status === 'refreshed') {
      try {
        const lr = await fetch('/list');
        zimsCache = await lr.json();
      } catch(e) {}
      renderManage();
    }
  } catch(e) {}
}

async function managePassword() {
  const has = await manageFetch('/manage/has-password').then(r => r.json()).catch(() => ({}));
  const errEl = document.getElementById('pw-error');
  const overlay = document.getElementById('pw-overlay');

  openPwModal(has.has_password ? 'Change password' : 'Set password', {placeholder: 'New password', hideRemember: true});
  document.getElementById('pw-remove-btn').style.display = has.has_password ? '' : 'none';

  _pwResolve = async function(newPw) {
    const body = {password: newPw};
    if (has.has_password && _manageToken) body.current = _manageToken;
    const res = await manageFetch('/manage/set-password', {
      method: 'POST',
      headers: {'Content-Type': 'application/json'},
      body: JSON.stringify(body)
    });
    if (!res.ok) {
      const d = await res.json().catch(() => ({}));
      errEl.textContent = d.error || 'Failed';
      errEl.style.display = 'block';
      overlay.classList.add('open');
      return;
    }
    if (newPw) {
      _manageToken = newPw;
      if (document.getElementById('pw-remember').checked) {
        sessionStorage.setItem('zimi_manage_pw', newPw);
      } else {
        sessionStorage.removeItem('zimi_manage_pw');
      }
    } else {
      _manageToken = '';
      sessionStorage.removeItem('zimi_manage_pw');
    }
    closePwModal();
    renderManage();
  };
}

async function manageClearPassword() {
  if (!confirm('Remove password protection?')) return;
  const body = {password: ''};
  if (_manageToken) body.current = _manageToken;
  const res = await manageFetch('/manage/set-password', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify(body)
  });
  if (res.ok) {
    _manageToken = '';
    sessionStorage.removeItem('zimi_manage_pw');
    closePwModal();
    renderManage();
  }
}

// ── Helpers ──
function _pdfViewerUrl(pdfUrl) {
  // pdfUrl is already percent-encoded (/w/zim/path%20name.pdf).
  // Don't encodeURIComponent — pdf.js passes file param directly to fetch.
  return '/static/pdfjs/web/viewer.html?file=' + pdfUrl;
}
function _articleUrl(zim, path) {
  return '/w/' + encodeURIComponent(zim) + '/' + path.split('/').map(encodeURIComponent).join('/');
}
function _titleFromPath(path) {
  return decodeURIComponent(path.split('/').pop() || '').replace(/_/g, ' ');
}
function _downloadFile(url) {
  var a = document.createElement('a');
  a.href = url; a.download = ''; a.style.display = 'none';
  document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
function _stepBackToArticle(prev, replaceState) {
  // Navigate reader to a previous article from history.
  // replaceState=true for in-app back (URL hasn't changed yet),
  // replaceState=false for browser back (URL already changed by popstate).
  currentArticle = { zim: prev.zim, path: prev.path };
  readerSource = prev.zim;
  var url = _articleUrl(prev.zim, prev.path);
  var lurl = url.toLowerCase();
  // PDFs need ?view=1 in URL so CDN cache serves SPA shell on reload
  if (replaceState) {
    var stateUrl = lurl.endsWith('.pdf') ? url + '?view=1' : url;
    history.replaceState({ mode: 'reader', zim: prev.zim, path: prev.path }, '', stateUrl);
  }
  if (lurl.endsWith('.pdf')) url = _pdfViewerUrl(url);
  var t = (prev.title || _titleFromPath(prev.path)) + ' \u2014 Zimi';
  document.title = t;
  _setWindowTitle(t);
  updateTopbar();
  openReader(url);
}

// ── Reader ──
function openReader(url) {
  // EPUBs: download (Gutenberg has HTML equivalents)
  var lurl = url.toLowerCase();
  if (lurl.endsWith('.epub')) {
    var extUrl = url + (url.includes('?') ? '&' : '?') + 'raw=1';
    if (!/^https?:\/\//.test(extUrl)) extUrl = location.origin + extUrl;
    _downloadFile(extUrl);
    return false;
  }
  // PDFs: render in embedded pdf.js viewer (skip if already a viewer URL)
  if (lurl.endsWith('.pdf') && !url.startsWith('/static/pdfjs/')) {
    url = _pdfViewerUrl(url);
  }
  readerOpen = true;
  const reader = document.getElementById('reader');
  const frame = document.getElementById('reader-frame');
  const loading = document.getElementById('reader-loading');

  mainView.classList.add('hidden');
  reader.classList.add('open');
  loading.classList.remove('hidden');

  // Punch-out button: for pdf.js viewer URLs, link to the raw PDF for download
  if (url.startsWith('/static/pdfjs/')) {
    var fileParam = new URL(url, location.origin).searchParams.get('file');
    newtabBtn.href = fileParam ? fileParam + (fileParam.includes('?') ? '&' : '?') + 'raw=1' : url;
  } else {
    newtabBtn.href = url;
  }

  frame.onload = function() {
    loading.classList.add('hidden');
    if (!readerOpen) return; // reader was closed — don't update title
    // Only intercept clicks in ZIM content, not in static viewer pages (pdf.js)
    var _frameLoc = ''; try { _frameLoc = frame.contentWindow.location.pathname; } catch(e) {}
    if (!_frameLoc.startsWith('/static/')) try {
      frame.contentDocument.addEventListener('click', function(e) {
        var a = e.target.closest('a[href]');
        if (!a) return;
        var href = a.getAttribute('href') || '';
        var lhref = href.toLowerCase();
        // Resolve URL against iframe's location (not document.baseURI which may point to original domain)
        var frameLoc = frame.contentWindow.location;
        var fullUrl;
        try { fullUrl = new URL(href, frameLoc.href).href; } catch(ex) { fullUrl = a.href; }
        // EPUB: download
        if (lhref.endsWith('.epub')) {
          e.preventDefault();
          if (!fullUrl.includes('raw=1')) fullUrl += (fullUrl.includes('?') ? '&' : '?') + 'raw=1';
          _downloadFile(fullUrl);
          return;
        }
        // In-ZIM links (same-origin /w/<zim>/<path>): route through openArticle for history tracking
        var wMatch = fullUrl.startsWith(location.origin) && fullUrl.match(/\/w\/([^\/]+)\/(.+)/);
        if (wMatch) {
          e.preventDefault();
          var linkZim = decodeURIComponent(wMatch[1]);
          var linkPath = decodeURIComponent(wMatch[2]).replace(/[?#].*$/, ''); // strip query and fragment
          openArticle(linkZim, linkPath);
          return;
        }
        // External links: open in new tab
        if (/^https?:\/\//.test(href) && !fullUrl.startsWith(location.origin)) {
          e.preventDefault();
          window.open(fullUrl, '_blank');
        }
      });
    } catch(e) { /* cross-origin — ZIM content loaded from a different origin */ }
    // Update document/window title from iframe content (skip for pdf.js — it reports
    // "PDF.js viewer" which overwrites the good title already set by openArticle)
    if (!_frameLoc.startsWith('/static/')) try {
      var iTitle = frame.contentDocument && frame.contentDocument.title;
      if (!iTitle) {
        // Fallback: extract from iframe URL path
        var seg = _titleFromPath(frame.contentWindow.location.pathname);
        if (seg && seg !== 'blank') iTitle = seg;
      }
      if (iTitle) {
        var t = iTitle + ' \u2014 Zimi';
        document.title = t;
        _setWindowTitle(t);
      }
    } catch(e) { /* cross-origin or closed */ }
  };
  // Use location.replace to avoid polluting parent history stack
  // (setting iframe.src adds a session history entry that breaks the back button)
  try { frame.contentWindow.location.replace(url); } catch(e) { frame.src = url; }
  updateTopbar();
}

function _pushArticleHistory(zim, path) {
  if (!zim || !path) return;
  // Use current document title if available (more accurate than URL-derived)
  var docTitle = document.title.replace(/ — Zimi$/, '');
  var fallback = _titleFromPath(path);
  var title = (docTitle && docTitle !== 'Zimi') ? docTitle : fallback;
  articleHistory.push({ zim: zim, path: path, title: title, timestamp: Date.now() });
  if (articleHistory.length > 50) articleHistory.shift();
}

function openArticle(zim, path, title) {
  var url = _articleUrl(zim, path);
  readerSource = zim;
  // EPUB: download (Gutenberg has HTML equivalents for all EPUBs)
  var lurl = url.toLowerCase();
  if (lurl.endsWith('.epub')) {
    var extUrl = location.origin + url + '?raw=1';
    _downloadFile(extUrl);
    return;
  }
  // Track article history: push current article before navigating away
  if (currentArticle) _pushArticleHistory(currentArticle.zim, currentArticle.path);
  currentArticle = { zim: zim, path: path };
  // Push state with the original /w/ URL (not the viewer URL) so reload serves SPA shell.
  // PDFs get ?view=1 so CDN caches don't serve raw PDF binary on reload.
  var pushUrl = lurl.endsWith('.pdf') ? url + (url.includes('?') ? '&' : '?') + 'view=1' : url;
  history.pushState({ mode: 'reader', zim: zim, path: path }, '', pushUrl);
  // PDF: route through pdf.js viewer (renders in reader iframe like any article)
  if (lurl.endsWith('.pdf')) {
    url = _pdfViewerUrl(url);
  }
  openReader(url);
  // Use explicit title if provided (e.g. from catalog or search results),
  // fall back to deriving from the URL path segment
  var readerTitle = title || _titleFromPath(path);
  if (readerTitle) {
    var t2 = readerTitle + ' — Zimi';
    document.title = t2;
    _setWindowTitle(t2);
  }
}

function closeReader() {
  if (!readerOpen) return;
  readerOpen = false;
  readerSource = null;
  currentArticle = null;
  articleHistory = [];
  document.getElementById('reader').classList.remove('open');
  // Use location.replace to avoid adding a history entry (iframe.src pollutes back button)
  var f = document.getElementById('reader-frame');
  try { f.contentWindow.location.replace('about:blank'); } catch(e) { f.src = 'about:blank'; }
  mainView.classList.remove('hidden');
  updateTopbar();
  _setWindowTitle('Zimi');
}

// ── Random ──
async function randomArticle(event) {
  if (event) event.preventDefault();
  if (mode === 'manage') { mode = 'home'; updateTopbar(); }
  const btn = document.getElementById('random-btn');
  btn.classList.add('rolling');
  setTimeout(() => btn.classList.remove('rolling'), 600);
  const zimParam = currentSource ? '?zim=' + encodeURIComponent(currentSource) : '';
  try {
    const res = await fetch('/random' + zimParam);
    const data = await res.json();
    if (!data.error) { openArticle(data.zim, data.path, data.title); return; }
    // Fallback for zimgit/PDF ZIMs: pick random doc from catalog
    if (currentSource) {
      const catRes = await fetch('/catalog?zim=' + encodeURIComponent(currentSource));
      const catData = await catRes.json();
      if (catData.documents && catData.documents.length) {
        const docs = catData.documents.filter(d => d.path);
        if (docs.length) {
          const pick = docs[Math.floor(Math.random() * docs.length)];
          openArticle(currentSource, pick.path, pick.title);
          return;
        }
      }
    }
  } catch(e) {}
}

// ── Keyboard ──
document.addEventListener('keydown', e => {
  if (e.key === 'Escape') {
    if (suggestDropdown.style.display !== 'none') { hideSuggest(); return; }
    if (readerOpen) { goBack(); return; }
    if (q.value) { q.value = ''; hideSuggest(); clearSearch(); return; }
    if (mode === 'source' || mode === 'manage') { enterHome(true); return; }
    return;
  }
  const tag = document.activeElement && document.activeElement.tagName;
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
  if (e.key === '/' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); q.focus(); }
  if (e.key === 'r' && !e.metaKey && !e.ctrlKey && !e.altKey && mode !== 'manage') { e.preventDefault(); randomArticle(e); }
});

// ── History ──
window.addEventListener('popstate', (e) => {
  hideSuggest();
  _hideHistoryTrail();
  // Step through article history when reader is open (mirrors in-app back button)
  if (readerOpen && articleHistory.length > 0) {
    _stepBackToArticle(articleHistory.pop(), false);
    return;
  }
  if (readerOpen) closeReader();
  q.value = '';
  activeCategories.clear();
  activeSourceFilters.clear();
  sourceAutoReader = false;
  // Route based on state, not URL (avoids re-opening reader on back)
  var s = e.state;
  if (s && s.mode === 'home' && s.scope) {
    enterScope(s.scope.type, s.scope.label, s.scope.zimNames, false);
  } else if (s && s.mode === 'home') {
    enterHome(false);
  } else if (s && s.mode === 'search' && s.query) {
    // Going back to search results — restore cached results instantly if available
    if (s.source) {
      _popstateNoAutoReader = true;
      enterSource(s.source, false);
      _popstateNoAutoReader = false;
    } else {
      mode = 'search'; currentSource = null; sourceAutoReader = false;
      sourceHeaderEl.style.display = 'none';
      mainView.classList.remove('hidden');
    }
    q.value = s.query;
    if (allResults && allResults._query === s.query && (allResults.results || []).length > 0) {
      // Instant restore from in-memory cache
      renderSearchResults(allResults, currentSource);
      updateTopbar();
    } else {
      doSearch(s.query, false);
    }
  } else if (s && s.mode === 'reader' && s.zim) {
    // Going back to a reader state — show the source page, don't re-open reader
    _popstateNoAutoReader = true;
    enterSource(s.zim, false);
    _popstateNoAutoReader = false;
  } else if (s && s.mode === 'source' && s.source) {
    // Going back to source — show source page, don't auto-open reader
    _popstateNoAutoReader = true;
    enterSource(s.source, false);
    _popstateNoAutoReader = false;
  } else {
    // Fallback: route from URL
    route(false);
  }
});

// ── Util ──
function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
function escAttr(s) { return esc(s).replace(/'/g, '&#39;').replace(/"/g, '&quot;'); }
// Escape for JS string literal inside HTML onclick attribute (survives HTML decode then JS parse)
function escJs(s) { return (s || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;'); }

// ── Desktop app integration (pywebview) ──
// pywebview injects window.pywebview AFTER page load, so we can't check at parse time.
// Instead, listen for the 'pywebviewready' event and set IS_DESKTOP then.
let IS_DESKTOP = false;

function _desktopInit() {
  IS_DESKTOP = true;
  console.log('[Zimi] Desktop mode enabled, pywebview bridge ready');
  // Check onboarding if init already ran (zimsCache populated)
  if (zimsCache !== null) _desktopCheckOnboarding();
}

function _setWindowTitle(title) {
  document.title = title;
  // pywebview bridge: try direct call, with fallback polling
  if (window.pywebview && window.pywebview.api && window.pywebview.api.set_title) {
    window.pywebview.api.set_title(title).catch(function(e) {
      console.warn('[Zimi] set_title failed:', e);
    });
  }
}

// Detect pywebview even if the 'pywebviewready' event was missed
// (can happen when load_url navigates to server after initial HTML load)
(function _detectDesktop() {
  if (window.pywebview && window.pywebview.api) {
    if (!IS_DESKTOP) _desktopInit();
    return;
  }
  // Poll for up to 10 seconds after page load
  let attempts = 0;
  const timer = setInterval(function() {
    if (window.pywebview && window.pywebview.api) {
      clearInterval(timer);
      if (!IS_DESKTOP) _desktopInit();
    } else if (++attempts > 50) {
      clearInterval(timer); // give up after 10s
    }
  }, 200);
})();

async function _desktopCheckOnboarding() {
  if (!IS_DESKTOP) return;
  // Only show onboarding if desktop + no ZIMs + first run
  if (zimsCache && zimsCache.length === 0) {
    try {
      const cfg = await pywebview.api.get_config();
      if (cfg.is_first_run) {
        const el = document.getElementById('onboard-path');
        if (el) el.value = cfg.zim_dir || '';
        document.getElementById('onboard-start').disabled = !cfg.zim_dir;
        document.getElementById('desktop-onboarding').classList.add('open');
      }
    } catch(e) {}
  }
}

async function desktopChooseFolder(inputId) {
  if (!IS_DESKTOP) return;
  const input = document.getElementById(inputId);
  try {
    const folder = await pywebview.api.choose_folder(input.value || null);
    if (folder) {
      input.value = folder;
      // Enable the Get Started button if this is onboarding
      if (inputId === 'onboard-path') {
        document.getElementById('onboard-start').disabled = false;
      }
    }
  } catch(e) {}
}

async function desktopFinishOnboarding() {
  if (!IS_DESKTOP) return;
  const path = document.getElementById('onboard-path').value;
  if (!path) return;
  try {
    const needsRestart = await pywebview.api.save_config({ zim_dir: path });
    document.getElementById('desktop-onboarding').classList.remove('open');
    if (needsRestart) {
      await pywebview.api.restart();
    }
  } catch(e) {}
}

// pywebview fires this event once the JS bridge is ready
window.addEventListener('pywebviewready', function() { _desktopInit(); });

// ── Server Settings overlay ──
let _settingsOriginal = {};

async function openSettings() {
  const overlay = document.getElementById('settings-overlay');
  const dirInput = document.getElementById('settings-dir');
  const portInput = document.getElementById('settings-port');
  const browseBtn = document.getElementById('settings-browse-btn');
  const msgEl = document.getElementById('settings-restart-msg');
  msgEl.className = 'settings-restart-msg';
  msgEl.textContent = '';

  if (IS_DESKTOP) {
    // Desktop mode: get config from pywebview bridge
    browseBtn.style.display = '';
    portInput.readOnly = false;
    document.getElementById('settings-save-btn').style.display = '';
    document.getElementById('settings-port-label').style.display = '';
    document.getElementById('settings-port-row').style.display = '';
    document.getElementById('settings-hint').style.display = '';
    try {
      const cfg = await pywebview.api.get_config();
      dirInput.value = cfg.zim_dir || '';
      portInput.value = cfg.port || 8899;
      _settingsOriginal = { zim_dir: cfg.zim_dir, port: cfg.port };
    } catch(e) {}
  } else {
    // Server mode: show read-only info + useful actions
    browseBtn.style.display = 'none';
    dirInput.readOnly = true;
    document.getElementById('settings-save-btn').style.display = 'none';
    document.getElementById('settings-port-label').style.display = 'none';
    document.getElementById('settings-port-row').style.display = 'none';
    document.getElementById('settings-hint').textContent = 'Settings are configured via environment variables or Docker.';
    try {
      const r = await manageFetch('/manage/stats');
      if (r.ok) {
        const s = await r.json();
        if (s.disk && s.disk.zim_dir) dirInput.value = s.disk.zim_dir;
      }
    } catch(e) {}
    _settingsOriginal = {};
  }

  // Show "Open in browser" link
  const browserLink = document.getElementById('settings-browser-link');
  const browserUrl = document.getElementById('settings-browser-url');
  const browserLabel = document.getElementById('settings-browser-label');
  if (IS_DESKTOP) {
    const url = 'http://localhost:' + (portInput.value || 8899);
    browserUrl.href = url;
    browserUrl.textContent = url;
    browserLabel.textContent = 'Access from any browser on this computer';
    browserLink.style.display = '';
  } else {
    browserLink.style.display = 'none';  // Not useful in browser mode
  }

  overlay.classList.add('open');
}

function closeSettings() {
  document.getElementById('settings-overlay').classList.remove('open');
}

async function settingsChooseFolder() {
  if (!IS_DESKTOP) return;
  const input = document.getElementById('settings-dir');
  try {
    const folder = await pywebview.api.choose_folder(input.value || null);
    if (folder) input.value = folder;
  } catch(e) {}
}

async function settingsSave() {
  const dirInput = document.getElementById('settings-dir');
  const portInput = document.getElementById('settings-port');
  const msgEl = document.getElementById('settings-restart-msg');
  const port = parseInt(portInput.value, 10);

  if (IS_DESKTOP) {
    const updates = {};
    if (dirInput.value !== _settingsOriginal.zim_dir) updates.zim_dir = dirInput.value;
    if (port !== _settingsOriginal.port) updates.port = port;
    if (Object.keys(updates).length === 0) { closeSettings(); return; }
    try {
      const needsRestart = await pywebview.api.save_config(updates);
      if (needsRestart) {
        msgEl.textContent = 'Restarting with new settings\u2026';
        msgEl.className = 'settings-restart-msg show';
        setTimeout(() => pywebview.api.restart(), 500);
        return;
      }
    } catch(e) {}
  }
  closeSettings();
}

let _refreshing = false;
async function settingsRefreshCache() {
  if (_refreshing) return;
  _refreshing = true;
  // Works both from settings overlay and standalone (manage view button)
  const msgEl = document.getElementById('settings-restart-msg');
  const btn = document.getElementById('refresh-cache-btn');
  const zimRow = document.querySelector('#manage-status .mc-value');
  if (btn) { btn.disabled = true; btn.textContent = '\u23F3 Refreshing\u2026'; }
  if (msgEl) {
    msgEl.textContent = 'Refreshing cache\u2026';
    msgEl.className = 'settings-restart-msg show';
  }
  try {
    await manageFetch('/manage/refresh', { method: 'POST' });
    if (msgEl) msgEl.textContent = 'Cache refreshed.';
    // Reload ZIM list
    try { const lr = await fetch('/list'); zimsCache = await lr.json(); } catch(e) {}
    if (btn) btn.textContent = '\u2705 Refreshed';
    if (msgEl && document.getElementById('settings-overlay').classList.contains('open')) {
      setTimeout(() => { closeSettings(); renderManage(); }, 800);
    } else {
      renderManage();
    }
  } catch(e) {
    if (msgEl) msgEl.textContent = 'Failed to refresh cache.';
    if (btn) btn.textContent = '\u274C Failed';
  } finally {
    setTimeout(() => { _refreshing = false; }, 2000);
  }
}

init();
</script>
</body>
</html>
