AkurAI Build
Menu

Public repository / d63e826eb482cb963477774b04fe8a84f23f0e45

AkurAI-Drive / backend/static/app.html

Latest change ddc31462ceddd45c4426bf90e65a21aedab2df32 - refactor(drive): share theme presentation shell by AkurAI Agent

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script>(function(){try{var m=document.cookie.match(/(?:^|; )akurai-theme=([^;]*)/);var t=(m?decodeURIComponent(m[1]):null)||localStorage.getItem("akurai-theme");if(!t)t=matchMedia("(prefers-color-scheme: light)").matches?"akurai-light":"akurai";document.documentElement.setAttribute("data-theme",t)}catch(e){}})()</script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AkurAI Drive — Dashboard</title>
<meta name="robots" content="noindex,nofollow">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@500;600;700;800&family=Inter:wght@400;450;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/themes.css">
<link rel="stylesheet" href="/shared-shell.css">
<style>
  /* ── TOP BAR ── */
  .topbar{position:sticky;top:0;z-index:40;backdrop-filter:blur(12px);
    background:color-mix(in srgb, var(--bg) 80%, transparent);border-bottom:1px solid var(--line)}
  .topbar-in{display:flex;align-items:center;justify-content:space-between;height:64px;gap:12px}
  .brand{display:flex;align-items:center;gap:11px;font-family:var(--display);font-weight:700;font-size:18px}
  .brand small{display:block;font-family:var(--body);font-weight:450;font-size:10.5px;
    letter-spacing:.16em;text-transform:uppercase;color:var(--faint)}
  .mark{width:32px;height:32px;flex:none}
  .topbar-right{display:flex;align-items:center;gap:12px;flex-shrink:0}
  .user-email{font-size:14px;color:var(--muted);font-weight:500;white-space:nowrap;
    overflow:hidden;text-overflow:ellipsis;max-width:220px}
  @media(max-width:520px){.user-email{display:none}}
  .admin-pill{display:none;align-items:center;font-size:11.5px;font-weight:600;
    letter-spacing:.04em;color:var(--on-accent);
    background:linear-gradient(92deg,var(--brand),var(--brand2));
    padding:4px 11px;border-radius:999px;white-space:nowrap}
  .signout{font-size:14px;font-weight:500;color:var(--muted);padding:7px 12px;
    border-radius:9px;transition:.15s}
  .signout:hover{color:var(--ink);background:rgba(255,255,255,.05)}

  /* ── MAIN LAYOUT ── */
  .main{padding:40px 0 64px}
  .section-head{display:flex;align-items:center;justify-content:space-between;gap:14px;
    margin-bottom:28px;flex-wrap:wrap}
  .section-head h2{font-size:clamp(22px,3.2vw,28px);font-weight:700}
  .section-head p{color:var(--muted);font-size:14.5px;margin:4px 0 0}

  /* ── BUCKET GRID ── */
  .bucket-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:18px}

  /* ── BUCKET CARD ── */
  .bucket-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:14px}
  .bucket-org{font-family:var(--display);font-size:17px;font-weight:700;color:var(--ink)}
  .plan-chip{font-size:11.5px;font-weight:600;letter-spacing:.04em;padding:4px 10px;
    border-radius:999px;white-space:nowrap}
  .plan-chip.plan-starter{background:color-mix(in srgb, var(--brand) 15%, transparent);color:var(--brand);border:1px solid color-mix(in srgb, var(--brand) 32%, transparent)}
  .plan-chip.plan-pro{background:linear-gradient(92deg,color-mix(in srgb, var(--brand) 20%, transparent),color-mix(in srgb, var(--brand2) 18%, transparent));
    color:var(--brand2);border:1px solid color-mix(in srgb, var(--brand2) 30%, transparent)}
  .plan-chip.plan-business{background:color-mix(in srgb, var(--accent) 14%, transparent);color:var(--accent);border:1px solid color-mix(in srgb, var(--accent) 32%, transparent)}
  .bucket-name{font-family:"SF Mono","Fira Code","Fira Mono","Cascadia Code",Consolas,monospace;
    font-size:13px;color:var(--brand);background:color-mix(in srgb, var(--brand) 10%, transparent);border:1px solid color-mix(in srgb, var(--brand) 20%, transparent);
    border-radius:8px;padding:8px 12px;word-break:break-all;margin-bottom:14px}
  .bucket-meta{display:flex;gap:16px;flex-wrap:wrap;font-size:13px;color:var(--faint)}
  .bucket-meta span{display:inline-flex;align-items:center;gap:5px}

  /* ── EMPTY STATE ── */
  .empty-card{border:1px dashed color-mix(in srgb, var(--brand) 30%, transparent);border-radius:var(--r);
    padding:48px 28px;text-align:center;
    background:linear-gradient(180deg,color-mix(in srgb, var(--brand) 6%, transparent),color-mix(in srgb, var(--brand) 2%, transparent))}
  .empty-ico{width:40px;height:40px;color:var(--brand);margin:0 auto 16px;display:block}
  .empty-card h3{font-size:19px;font-weight:700;margin-bottom:8px}
  .empty-card p{color:var(--muted);font-size:15px;margin:0 0 22px}

  /* ── LOADING ── */
  .loading{position:fixed;inset:0;z-index:80;display:flex;align-items:center;justify-content:center;
    background:var(--bg)}
  .spinner{width:44px;height:44px;border-radius:50%;
    border:3px solid color-mix(in srgb, var(--brand) 22%, transparent);border-top-color:var(--brand);
    animation:spin .7s linear infinite}
  @keyframes spin{to{transform:rotate(360deg)}}

  /* ── MODAL ── */
  .modal{position:fixed;inset:0;z-index:60;display:none;place-items:center;
    padding:20px;background:color-mix(in srgb, var(--bg) 68%, transparent);backdrop-filter:blur(6px)}
  .modal.open{display:grid}
  .sheet{width:100%;max-width:460px;border:1px solid var(--line2);border-radius:20px;
    background:var(--panel);box-shadow:0 40px 100px rgba(0,0,0,.6);
    overflow:hidden;animation:pop .18s ease}
  @keyframes pop{from{transform:translateY(8px) scale(.99);opacity:0}to{transform:none;opacity:1}}
  .sheet-h{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:22px 22px 6px}
  .sheet-h h3{font-size:19px;margin:0}
  .sheet-h p{color:var(--muted);font-size:13.5px;margin:5px 0 0}
  .x-btn{cursor:pointer;color:var(--faint);background:none;border:0;font-size:22px;
    line-height:1;padding:2px 6px;border-radius:6px;transition:.12s}
  .x-btn:hover{color:var(--ink);background:rgba(255,255,255,.06)}
  form.create-form{padding:14px 22px 22px;display:grid;gap:14px}
  .field label{display:block;font-size:13px;font-weight:500;color:var(--muted);margin-bottom:6px}
  .field input,.field select{width:100%;background:color-mix(in srgb, var(--bg2) 74%, var(--panel));border:1px solid var(--line2);
    border-radius:11px;color:var(--ink);font-family:var(--body);font-size:15px;
    padding:11px 13px;outline:none;transition:.12s}
  .field input:focus,.field select:focus{border-color:var(--brand)}
  .field select option{background:var(--panel)}
  .gate-field{border:1px dashed color-mix(in srgb, var(--accent) 45%, transparent);border-radius:12px;padding:13px;
    background:color-mix(in srgb, var(--accent) 8%, transparent)}
  .gate-field label{color:var(--accent)}
  .gate-field .hint{font-size:12px;color:var(--faint);margin-top:7px;line-height:1.4}
  .msg{font-size:13.5px;padding:10px 12px;border-radius:10px;display:none}
  .msg.show{display:block}
  .msg.err{background:color-mix(in srgb, var(--danger) 12%, transparent);color:var(--danger);border:1px solid color-mix(in srgb, var(--danger) 34%, transparent)}
  .create-note{font-size:12.5px;color:var(--faint);text-align:center}

  /* ── TOAST ── */
  .toast{position:fixed;bottom:28px;left:50%;transform:translateX(-50%) translateY(20px);
    z-index:90;font-size:14px;font-weight:500;
    background:color-mix(in srgb, var(--good) 12%, transparent);color:var(--good);
    border:1px solid color-mix(in srgb, var(--good) 34%, transparent);border-radius:12px;padding:12px 20px;
    white-space:nowrap;opacity:0;transition:opacity .22s,transform .22s;pointer-events:none}
  .toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
</style>
</head>
<body>

<!-- TOP BAR (hidden until auth resolves) -->
<header class="topbar" id="topbar" style="display:none" role="banner">
  <div class="container topbar-in">
    <a class="brand" href="/">
      <svg class="mark" viewBox="0 0 32 32" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" clip-rule="evenodd" d="M13.38 3.23c1.05-2.23 4.19-2.23 5.24 0l11.05 23.62A2.91 2.91 0 0 1 27.03 31h-3.42c-1.17 0-2.22-.7-2.67-1.78l-2.05-4.93h-5.78l-2.05 4.93A2.9 2.9 0 0 1 8.39 31H4.97a2.91 2.91 0 0 1-2.64-4.15L13.38 3.23ZM16 11.17l-5.13 13.1h10.26L16 11.17Z"/><path d="M10.38 20.54h11.24a2.23 2.23 0 0 1 0 4.46H10.38a2.23 2.23 0 0 1 0-4.46Z"/></svg>
      <span>AkurAI&nbsp;Drive<small>Hosted in Iceland</small></span>
    </a>
    <div class="topbar-right">
      <span class="user-email" id="user-email" aria-label="Signed in as"></span>
      <span class="admin-pill" id="admin-pill" aria-label="Administrator">Admin</span>
      <span class="theme-picker" data-theme-picker></span>
      <a class="signout" href="/auth/logout">Sign out</a>
    </div>
  </div>
</header>

<!-- MAIN (hidden until auth resolves) -->
<main class="main" id="main" style="display:none">
  <div class="container">
    <div class="section-head">
      <div>
        <h2>Your storage</h2>
        <p>Private, S3-backed buckets provisioned for your organisation.</p>
      </div>
      <button class="btn btn-primary btn-sm" id="create-btn" aria-haspopup="dialog">
        <svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
          <path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/>
        </svg>
        Create storage
      </button>
    </div>
    <div class="bucket-grid" id="buckets" role="list" aria-live="polite" aria-label="Storage buckets"></div>
  </div>
</main>

<!-- LOADING -->
<div class="loading" id="loading" role="status" aria-label="Loading">
  <div class="spinner"></div>
</div>

<!-- TOAST -->
<div class="toast" id="toast" role="status" aria-live="polite" aria-atomic="true"></div>

<!-- CREATE STORAGE MODAL -->
<div class="modal" id="create-modal" role="dialog" aria-modal="true" aria-labelledby="cm-title">
  <div class="sheet">
    <div class="sheet-h">
      <div>
        <h3 id="cm-title">Create storage</h3>
        <p>Provision a private, S3-backed bucket for your organisation.</p>
      </div>
      <button class="x-btn" id="cm-close" aria-label="Close dialog">&#x00D7;</button>
    </div>
    <form class="create-form" id="create-form" novalidate>
      <div class="msg" id="cm-err" role="alert"></div>
      <div class="field">
        <label for="cm-org">Organisation name</label>
        <input id="cm-org" name="org" type="text" placeholder="Acme ehf." required autocomplete="organization">
      </div>
      <div class="field">
        <label for="cm-email">Work email</label>
        <input id="cm-email" name="email" type="email" placeholder="you@company.is" required autocomplete="email">
      </div>
      <div class="field">
        <label for="cm-plan">Plan</label>
        <select id="cm-plan" name="plan">
          <option value="starter">Starter — 100 GB</option>
          <option value="pro" selected>Pro — 1 TB</option>
          <option value="business">Business — custom</option>
        </select>
      </div>
      <div class="field gate-field">
        <label for="cm-gate">Access code</label>
        <input id="cm-gate" name="gate" type="password" placeholder="Enter your access code" autocomplete="off" required>
        <div class="hint">Early access — required to provision storage. Contact AkurAI to request a code.</div>
      </div>
      <button class="btn btn-primary" type="submit" id="cm-submit">Create storage</button>
      <div class="create-note">Your bucket is provisioned in Iceland. You can delete it at any time.</div>
    </form>
  </div>
</div>

<script type="module" src="/theme.js"></script>
<script type="module">
  import { initClientLogging } from '/js/monitor-client.js';
  initClientLogging({ app: 'akurai-drive-api', endpoint: '/api/client-logs' });

  // ── DOM refs ──────────────────────────────────────────────
  const topbar     = document.getElementById('topbar');
  const mainEl     = document.getElementById('main');
  const loadingEl  = document.getElementById('loading');
  const toastEl    = document.getElementById('toast');
  const bucketsEl  = document.getElementById('buckets');
  const modal      = document.getElementById('create-modal');
  const createBtn  = document.getElementById('create-btn');
  const closeBtn   = document.getElementById('cm-close');
  const form       = document.getElementById('create-form');
  const formOrg    = document.getElementById('cm-org');
  const formEmail  = document.getElementById('cm-email');
  const formErr    = document.getElementById('cm-err');
  const formSubmit = document.getElementById('cm-submit');
  const adminPill  = document.getElementById('admin-pill');
  const userEmailEl = document.getElementById('user-email');

  // ── State ─────────────────────────────────────────────────
  let me = null;
  let buckets = [];
  let lastFocus = null;
  let toastTimer = null;

  // ── Utility ───────────────────────────────────────────────
  function escHtml(s) {
    return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  }

  function fmtDate(unixSec) {
    if (!unixSec) return '—';
    return new Date(Number(unixSec) * 1000).toLocaleDateString('en-GB', {
      day: 'numeric', month: 'short', year: 'numeric'
    });
  }

  const planLabels = { starter: 'Starter', pro: 'Pro', business: 'Business' };
  function planLabel(plan) { return planLabels[plan] ?? plan; }

  // ── Toast ─────────────────────────────────────────────────
  function showToast(msg) {
    toastEl.textContent = msg;
    toastEl.classList.add('show');
    clearTimeout(toastTimer);
    toastTimer = setTimeout(() => toastEl.classList.remove('show'), 4500);
  }

  // ── Render buckets ────────────────────────────────────────
  function renderBuckets() {
    if (!buckets.length) {
      bucketsEl.innerHTML = `
        <div class="empty-card" role="listitem">
          <svg class="empty-ico" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <ellipse cx="12" cy="6" rx="8" ry="3" stroke="currentColor" stroke-width="1.8"/>
            <path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" stroke="currentColor" stroke-width="1.8"/>
          </svg>
          <h3>No storage yet</h3>
          <p>Create your first storage bucket to get started with AkurAI Drive.</p>
          <button class="btn btn-primary" id="empty-create-btn">Create storage</button>
        </div>`;
      document.getElementById('empty-create-btn').addEventListener('click', openModal);
      return;
    }

    bucketsEl.innerHTML = buckets.map(b => {
      const planClass = `plan-${escHtml(b.plan)}`;
      return `
        <article class="card" role="listitem">
          <div class="bucket-head">
            <span class="bucket-org">${escHtml(b.org)}</span>
            <span class="plan-chip ${planClass}">${escHtml(planLabel(b.plan))}</span>
          </div>
          <div class="bucket-name" title="S3 bucket name">${escHtml(b.bucket)}</div>
          <div class="bucket-meta">
            <span>
              <svg width="13" height="13" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"/>
                <path d="M3 12h18M12 3c2.5 2.7 2.5 15 0 18" stroke="currentColor" stroke-width="1.6"/>
              </svg>
              ${escHtml(b.region)}
            </span>
            <span>
              <svg width="13" height="13" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <rect x="3" y="4" width="18" height="17" rx="2" stroke="currentColor" stroke-width="1.8"/>
                <path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
              </svg>
              ${escHtml(fmtDate(b.created))}
            </span>
          </div>
        </article>`;
    }).join('');
  }

  // ── Modal focus trap (minimal) ────────────────────────────
  function openModal() {
    lastFocus = document.activeElement;
    formErr.className = 'msg';
    modal.classList.add('open');
    // Slight delay so animation starts before focus shifts
    requestAnimationFrame(() => formOrg.focus());
  }

  function closeModal() {
    modal.classList.remove('open');
    if (lastFocus) lastFocus.focus();
  }

  // ── Load storage list ─────────────────────────────────────
  async function loadBuckets() {
    try {
      const res = await fetch('/api/storage');
      if (res.status === 401) { location.href = '/auth/login'; return; }
      const data = await res.json().catch(() => ({}));
      buckets = Array.isArray(data.buckets) ? data.buckets : [];
    } catch {
      buckets = [];
    }
    renderBuckets();
  }

  // ── Init ──────────────────────────────────────────────────
  async function init() {
    let meRes;
    try {
      meRes = await fetch('/api/me');
    } catch {
      location.href = '/auth/login';
      return;
    }

    if (meRes.status === 401) {
      location.href = '/auth/login';
      return;
    }

    const meData = await meRes.json().catch(() => null);
    if (!meData || meData.authenticated === false) {
      location.href = '/auth/login';
      return;
    }
    me = meData;

    // Populate topbar
    userEmailEl.textContent = me.email ?? '';
    if (me.is_admin) adminPill.style.display = 'inline-flex';

    // Prefill email in modal form
    formEmail.value = me.email ?? '';

    // Reveal UI
    loadingEl.style.display = 'none';
    topbar.style.display = '';
    mainEl.style.display = '';

    // Load storage list
    await loadBuckets();
  }

  // ── Event wiring ──────────────────────────────────────────
  createBtn.addEventListener('click', openModal);
  closeBtn.addEventListener('click', closeModal);

  modal.addEventListener('click', e => {
    if (e.target === modal) closeModal();
  });

  document.addEventListener('keydown', e => {
    if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
  });

  form.addEventListener('submit', async e => {
    e.preventDefault();
    formErr.className = 'msg';

    const org  = form.org.value.trim();
    const email = form.email.value.trim();
    const plan  = form.plan.value;
    const code  = form.gate.value;

    if (!org || !email || !code) {
      formErr.textContent = 'Please fill in every field.';
      formErr.className = 'msg show err';
      if (!org) { formOrg.focus(); return; }
      if (!email) { formEmail.focus(); return; }
      return;
    }

    formSubmit.disabled = true;
    formSubmit.textContent = 'Creating…';

    try {
      const res = await fetch('/api/storage', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-Drive-Access-Code': code
        },
        body: JSON.stringify({ org, email, plan })
      });

      if (res.status === 201) {
        closeModal();
        form.reset();
        formEmail.value = me.email ?? '';
        showToast('Storage created — your bucket is being provisioned.');
        await loadBuckets();
      } else if (res.status === 403) {
        formErr.textContent = "That access code isn't valid (early access).";
        formErr.className = 'msg show err';
      } else if (res.status === 401) {
        location.href = '/auth/login';
      } else {
        const body = await res.json().catch(() => ({}));
        formErr.textContent = body.error ?? 'Something went wrong. Please try again.';
        formErr.className = 'msg show err';
      }
    } catch {
      formErr.textContent = 'Network error — please try again.';
      formErr.className = 'msg show err';
    } finally {
      formSubmit.disabled = false;
      formSubmit.textContent = 'Create storage';
    }
  });

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