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 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">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
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>