Initial commit
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
/* ============================================================
|
||||
Shorebird Self-Hosted Dashboard — Complete Stylesheet
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--primary: #0891b2;
|
||||
--primary-dark: #0e7490;
|
||||
--primary-light: #cffafe;
|
||||
--accent: #06b6d4;
|
||||
--bg: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--text: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--danger-light: #fef2f2;
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.05);
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.1), 0 1px 2px rgba(0,0,0,.06);
|
||||
--shadow-lg: 0 4px 6px rgba(0,0,0,.07), 0 2px 4px rgba(0,0,0,.06);
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- Login Page ---- */
|
||||
.login-container {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh; padding: 20px;
|
||||
}
|
||||
.login-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 40px;
|
||||
width: 100%; max-width: 420px;
|
||||
}
|
||||
.login-card h1 {
|
||||
font-size: 28px; font-weight: 700; margin-bottom: 4px;
|
||||
color: var(--text); text-align: center;
|
||||
}
|
||||
.login-card .logo-icon {
|
||||
font-size: 48px; text-align: center; margin-bottom: 8px;
|
||||
}
|
||||
.login-card .subtitle {
|
||||
color: var(--text-secondary); text-align: center;
|
||||
margin-bottom: 28px; font-size: 14px;
|
||||
}
|
||||
.login-error {
|
||||
background: var(--danger-light); color: var(--danger);
|
||||
padding: 10px 14px; border-radius: var(--radius);
|
||||
margin-bottom: 16px; font-size: 13px; display: none;
|
||||
}
|
||||
|
||||
/* ---- Layout ---- */
|
||||
.app-container { display: none; }
|
||||
.app-container.active { display: block; }
|
||||
|
||||
.sidebar {
|
||||
position: fixed; top: 0; left: 0; bottom: 0;
|
||||
width: 240px; background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
z-index: 100; padding: 0;
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 20px 24px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.sidebar-header .logo {
|
||||
width: 32px; height: 32px; background: var(--primary);
|
||||
border-radius: 8px; display: flex; align-items: center;
|
||||
justify-content: center; color: #fff; font-weight: 700;
|
||||
font-size: 18px;
|
||||
}
|
||||
.sidebar-header h2 { font-size: 16px; font-weight: 600; }
|
||||
.sidebar-nav { flex: 1; padding: 12px 8px; }
|
||||
.sidebar-nav a {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px; border-radius: var(--radius);
|
||||
color: var(--text-secondary); text-decoration: none;
|
||||
font-size: 14px; font-weight: 500; transition: all .15s;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.sidebar-nav a:hover { background: #f1f5f9; color: var(--text); }
|
||||
.sidebar-nav a.active {
|
||||
background: var(--primary-light); color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar-nav a .nav-icon { font-size: 18px; width: 24px; text-align: center; }
|
||||
.sidebar-footer {
|
||||
padding: 16px 24px; border-top: 1px solid var(--border);
|
||||
font-size: 13px; color: var(--text-muted);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 240px; padding: 32px 40px; min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- Top Bar ---- */
|
||||
.top-bar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.top-bar h2 { font-size: 24px; font-weight: 700; }
|
||||
.top-bar .user-info {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
font-size: 14px; color: var(--text-secondary);
|
||||
}
|
||||
.top-bar .btn-sm {
|
||||
padding: 6px 14px; font-size: 13px; background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.top-bar .btn-sm:hover { background: var(--danger-light); color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
/* ---- Cards & Stats ---- */
|
||||
.stats-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px; margin-bottom: 28px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--surface); border-radius: var(--radius);
|
||||
border: 1px solid var(--border); padding: 20px 24px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.stat-card .stat-value {
|
||||
font-size: 32px; font-weight: 700; color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-card .stat-label {
|
||||
font-size: 13px; color: var(--text-muted); margin-top: 4px;
|
||||
}
|
||||
.stat-card.primary { border-left: 3px solid var(--primary); }
|
||||
.stat-card.success { border-left: 3px solid var(--success); }
|
||||
.stat-card.warning { border-left: 3px solid var(--warning); }
|
||||
|
||||
/* ---- Tables ---- */
|
||||
.card {
|
||||
background: var(--surface); border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border); box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card-header {
|
||||
padding: 18px 24px; border-bottom: 1px solid var(--border);
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.card-header h3 { font-size: 16px; font-weight: 600; }
|
||||
.card-body { padding: 0; }
|
||||
|
||||
table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
text-align: left; padding: 12px 24px;
|
||||
font-size: 12px; font-weight: 600; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: .5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
}
|
||||
tbody td {
|
||||
padding: 12px 24px; border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 14px;
|
||||
}
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 10px; border-radius: 100px;
|
||||
font-size: 12px; font-weight: 500;
|
||||
}
|
||||
.badge-success { background: #d1fae5; color: #065f46; }
|
||||
.badge-warning { background: #fef3c7; color: #92400e; }
|
||||
.badge-danger { background: #fee2e2; color: #991b1b; }
|
||||
.badge-info { background: #dbeafe; color: #1e40af; }
|
||||
|
||||
.code {
|
||||
font-family: var(--mono); font-size: 13px;
|
||||
background: #f1f5f9; padding: 2px 8px; border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 18px; border-radius: var(--radius);
|
||||
font-size: 14px; font-weight: 500; cursor: pointer;
|
||||
border: 1px solid transparent; transition: all .15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.btn-primary:hover { background: var(--primary-dark); }
|
||||
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.btn-danger:hover { opacity: .85; }
|
||||
.btn-outline { background: #fff; color: var(--text); border-color: var(--border); }
|
||||
.btn-outline:hover { background: #f8fafc; }
|
||||
.btn-ghost {
|
||||
background: transparent; border-color: transparent;
|
||||
color: var(--text-secondary); padding: 6px 10px;
|
||||
}
|
||||
.btn-ghost:hover { background: #f1f5f9; color: var(--text); }
|
||||
|
||||
/* ---- Forms ---- */
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label {
|
||||
display: block; margin-bottom: 5px;
|
||||
font-size: 13px; font-weight: 600; color: var(--text-secondary);
|
||||
}
|
||||
.form-group input, .form-group select {
|
||||
width: 100%; padding: 10px 14px; border: 1px solid var(--border);
|
||||
border-radius: var(--radius); font-size: 14px; font-family: var(--font);
|
||||
transition: border-color .15s; outline: none;
|
||||
background: var(--surface);
|
||||
}
|
||||
.form-group input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(8,145,178,.1); }
|
||||
.form-hint { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* ---- Modals ---- */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(15,23,42,.5); z-index: 200;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal {
|
||||
background: var(--surface); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg); padding: 28px; width: 100%;
|
||||
max-width: 480px; max-height: 80vh; overflow-y: auto;
|
||||
}
|
||||
.modal h3 { font-size: 18px; font-weight: 600; margin-bottom: 20px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }
|
||||
|
||||
/* ---- Toast ---- */
|
||||
.toast-container {
|
||||
position: fixed; top: 20px; right: 20px; z-index: 300;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.toast {
|
||||
padding: 12px 20px; border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg); font-size: 14px; font-weight: 500;
|
||||
animation: slideIn .25s ease-out;
|
||||
max-width: 380px;
|
||||
}
|
||||
.toast-success { background: #065f46; color: #fff; }
|
||||
.toast-error { background: #991b1b; color: #fff; }
|
||||
@keyframes slideIn { from { opacity: 0; transform: translateX(40px); } to { opacity: 1; transform: translateX(0); } }
|
||||
|
||||
/* ---- Empty State ---- */
|
||||
.empty-state {
|
||||
text-align: center; padding: 60px 20px;
|
||||
}
|
||||
.empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-state p { color: var(--text-muted); font-size: 14px; }
|
||||
|
||||
/* ---- Section visibility ---- */
|
||||
.page-section { display: none; }
|
||||
.page-section.active { display: block; }
|
||||
|
||||
/* ---- Spinner ---- */
|
||||
.spinner {
|
||||
display: inline-block; width: 20px; height: 20px;
|
||||
border: 2px solid var(--border); border-top-color: var(--primary);
|
||||
border-radius: 50%; animation: spin .6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.loading { text-align: center; padding: 40px; color: var(--text-muted); }
|
||||
|
||||
/* ---- Server info card ---- */
|
||||
.info-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
||||
}
|
||||
.info-item { }
|
||||
.info-item .info-label { font-size: 12px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; }
|
||||
.info-item .info-value { font-size: 14px; color: var(--text); margin-top: 2px; word-break: break-all; }
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { width: 200px; }
|
||||
.main-content { margin-left: 200px; padding: 20px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.sidebar { display: none; }
|
||||
.main-content { margin-left: 0; }
|
||||
.stats-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ---- Tab bar for settings ---- */
|
||||
.tab-bar {
|
||||
display: flex; gap: 0; border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tab-bar button {
|
||||
padding: 10px 20px; border: none; background: none;
|
||||
font-size: 14px; font-weight: 500; color: var(--text-secondary);
|
||||
cursor: pointer; border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px; transition: all .15s;
|
||||
}
|
||||
.tab-bar button.active {
|
||||
color: var(--primary); border-bottom-color: var(--primary); font-weight: 600;
|
||||
}
|
||||
.tab-bar button:hover { color: var(--text); }
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Shorebird Self-Hosted</title>
|
||||
<link rel="stylesheet" href="/web/css/style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='%230891b2'/><text x='50%25' y='55%25' text-anchor='middle' dy='.1em' font-size='20' fill='white'>🐦</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============================================================
|
||||
LOGIN PAGE
|
||||
============================================================ -->
|
||||
<div id="loginPage" class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="logo-icon">🐦</div>
|
||||
<h1>Shorebird</h1>
|
||||
<p class="subtitle">Self-Hosted Dashboard</p>
|
||||
<div id="loginError" class="login-error"></div>
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="loginEmail">Email</label>
|
||||
<input type="email" id="loginEmail" placeholder="admin@example.com" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="loginPassword">Password</label>
|
||||
<input type="password" id="loginPassword" placeholder="Enter password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width:100%;justify-content:center;padding:12px;">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
<p style="text-align:center;margin-top:16px;font-size:13px;color:var(--text-muted);">
|
||||
No account? <a href="#" id="showRegister" style="color:var(--primary);">Register</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration modal on login page -->
|
||||
<div id="registerOverlay" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3>Create Account</h3>
|
||||
<form id="registerForm">
|
||||
<div class="form-group">
|
||||
<label for="regName">Name</label>
|
||||
<input type="text" id="regName" placeholder="Your name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="regEmail">Email</label>
|
||||
<input type="email" id="regEmail" placeholder="you@example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="regPassword">Password</label>
|
||||
<input type="password" id="regPassword" placeholder="Min 6 characters" required minlength="6">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeRegister()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Register & Sign In</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
APP SHELL (hidden until authenticated)
|
||||
============================================================ -->
|
||||
<div id="appContainer" class="app-container">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">🐦</div>
|
||||
<h2>Shorebird</h2>
|
||||
</div>
|
||||
<nav class="sidebar-nav" id="sidebarNav">
|
||||
<a href="#dashboard" class="active" data-page="dashboard">
|
||||
<span class="nav-icon">📊</span> Dashboard
|
||||
</a>
|
||||
<a href="#apps" data-page="apps">
|
||||
<span class="nav-icon">📱</span> Apps
|
||||
</a>
|
||||
<a href="#users" data-page="users">
|
||||
<span class="nav-icon">👥</span> Users
|
||||
</a>
|
||||
<a href="#settings" data-page="settings">
|
||||
<span class="nav-icon">⚙️</span> Settings
|
||||
</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer" id="sidebarFooter">
|
||||
Shorebird v1.0 · Self-Hosted
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="main-content">
|
||||
<!-- Top Bar -->
|
||||
<div class="top-bar">
|
||||
<h2 id="pageTitle">Dashboard</h2>
|
||||
<div class="user-info">
|
||||
<span id="currentUserEmail">—</span>
|
||||
<button class="btn-sm" id="logoutBtn" title="Sign out">Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast container -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- ================================================
|
||||
DASHBOARD PAGE
|
||||
================================================ -->
|
||||
<section id="page-dashboard" class="page-section active">
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card primary"><div class="stat-value" id="statApps">—</div><div class="stat-label">Apps</div></div>
|
||||
<div class="stat-card success"><div class="stat-value" id="statPatches">—</div><div class="stat-label">Patches</div></div>
|
||||
<div class="stat-card warning"><div class="stat-value" id="statUsers">—</div><div class="stat-label">Users</div></div>
|
||||
<div class="stat-card"><div class="stat-value" id="statOrganizations">—</div><div class="stat-label">Organizations</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>Recent Apps</h3></div>
|
||||
<div class="card-body" id="recentAppsTable"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================================================
|
||||
APPS PAGE
|
||||
================================================ -->
|
||||
<section id="page-apps" class="page-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>All Applications</h3>
|
||||
<button class="btn btn-primary" onclick="openCreateAppModal()">+ New App</button>
|
||||
</div>
|
||||
<div class="card-body" id="appsTable"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================================================
|
||||
USERS PAGE
|
||||
================================================ -->
|
||||
<section id="page-users" class="page-section">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>All Users</h3>
|
||||
<button class="btn btn-primary" onclick="openCreateUserModal()">+ New User</button>
|
||||
</div>
|
||||
<div class="card-body" id="usersTable"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================================================
|
||||
SETTINGS PAGE
|
||||
================================================ -->
|
||||
<section id="page-settings" class="page-section">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>Server Configuration</h3></div>
|
||||
<div class="card-body" style="padding:24px;">
|
||||
<div class="info-grid" id="serverInfo"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="margin-top:20px;">
|
||||
<div class="card-header"><h3>API Token</h3></div>
|
||||
<div class="card-body" style="padding:24px;">
|
||||
<p style="font-size:14px;color:var(--text-secondary);margin-bottom:12px;">
|
||||
Use this token to authenticate the Shorebird CLI:
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<input type="text" id="apiTokenDisplay" readonly
|
||||
style="font-family:var(--mono);font-size:13px;cursor:pointer;"
|
||||
onclick="this.select();document.execCommand('copy')"
|
||||
title="Click to copy">
|
||||
</div>
|
||||
<p class="form-hint">Click the token to copy. Set as <code>SHOREBIRD_TOKEN</code> env var.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
MODALS
|
||||
============================================================ -->
|
||||
|
||||
<!-- Create App Modal -->
|
||||
<div id="createAppOverlay" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3>Create New App</h3>
|
||||
<form id="createAppForm">
|
||||
<div class="form-group">
|
||||
<label for="appDisplayName">Display Name</label>
|
||||
<input type="text" id="appDisplayName" placeholder="My Flutter App" required>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('createAppOverlay')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create App</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<div id="createUserOverlay" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3>Create New User</h3>
|
||||
<form id="createUserForm">
|
||||
<div class="form-group">
|
||||
<label for="newUserName">Name</label>
|
||||
<input type="text" id="newUserName" placeholder="Full name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newUserEmail">Email</label>
|
||||
<input type="email" id="newUserEmail" placeholder="user@example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newUserPassword">Password</label>
|
||||
<input type="password" id="newUserPassword" placeholder="Min 6 characters" required minlength="6">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('createUserOverlay')">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Delete Modal -->
|
||||
<div id="confirmModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3 id="confirmTitle">Confirm</h3>
|
||||
<p id="confirmMessage" style="font-size:14px;color:var(--text-secondary);"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-outline" onclick="closeModal('confirmModal')">Cancel</button>
|
||||
<button class="btn btn-danger" id="confirmBtn" onclick="executeConfirm()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/web/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* Shorebird Self-Hosted Dashboard — Complete SPA Application
|
||||
* Vanilla JS, no framework dependencies.
|
||||
*/
|
||||
|
||||
// ---- State ----
|
||||
const state = {
|
||||
token: localStorage.getItem('shorebird_token') || null,
|
||||
user: null,
|
||||
apps: [],
|
||||
users: [],
|
||||
confirmCallback: null,
|
||||
};
|
||||
|
||||
// ---- API Client ----
|
||||
const api = {
|
||||
base: '/api/v1',
|
||||
auth: '/auth',
|
||||
|
||||
headers() {
|
||||
const h = { 'Content-Type': 'application/json' };
|
||||
if (state.token) h['Authorization'] = `Bearer ${state.token}`;
|
||||
return h;
|
||||
},
|
||||
|
||||
async request(method, path, body) {
|
||||
const url = path.startsWith('/auth') ? path : `${this.base}${path}`;
|
||||
const opts = { method, headers: this.headers() };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(url, opts);
|
||||
if (res.status === 204) return null;
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) { logout(); throw new Error('Unauthorized'); }
|
||||
throw new Error(data.message || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
get(path) { return this.request('GET', path); },
|
||||
post(path, body) { return this.request('POST', path, body); },
|
||||
patch(path, body) { return this.request('PATCH', path, body); },
|
||||
del(path) { return this.request('DELETE', path); },
|
||||
};
|
||||
|
||||
// ---- Navigation ----
|
||||
function navigate(page) {
|
||||
document.querySelectorAll('.page-section').forEach(s => s.classList.remove('active'));
|
||||
const section = document.getElementById(`page-${page}`);
|
||||
if (section) section.classList.add('active');
|
||||
|
||||
document.querySelectorAll('#sidebarNav a').forEach(a => a.classList.remove('active'));
|
||||
const link = document.querySelector(`#sidebarNav a[data-page="${page}"]`);
|
||||
if (link) link.classList.add('active');
|
||||
|
||||
const titles = { dashboard: 'Dashboard', apps: 'Apps', users: 'Users', settings: 'Settings' };
|
||||
document.getElementById('pageTitle').textContent = titles[page] || page;
|
||||
|
||||
if (page === 'dashboard') loadDashboard();
|
||||
if (page === 'apps') loadApps();
|
||||
if (page === 'users') loadUsers();
|
||||
if (page === 'settings') loadSettings();
|
||||
}
|
||||
|
||||
// ---- Toast Notifications ----
|
||||
function showToast(message, type) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => { toast.remove(); }, 4000);
|
||||
}
|
||||
|
||||
// ---- Modals ----
|
||||
function openModal(id) { document.getElementById(id).classList.add('active'); }
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('active'); }
|
||||
|
||||
function confirmAction(title, message, callback) {
|
||||
document.getElementById('confirmTitle').textContent = title;
|
||||
document.getElementById('confirmMessage').textContent = message;
|
||||
state.confirmCallback = callback;
|
||||
openModal('confirmModal');
|
||||
}
|
||||
function executeConfirm() {
|
||||
if (state.confirmCallback) state.confirmCallback();
|
||||
closeModal('confirmModal');
|
||||
}
|
||||
|
||||
// ---- Auth ----
|
||||
async function login(email, password) {
|
||||
const data = await api.request('POST', '/auth/token', { email, password });
|
||||
state.token = data.token;
|
||||
localStorage.setItem('shorebird_token', data.token);
|
||||
await loadCurrentUser();
|
||||
showApp();
|
||||
navigate('dashboard');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
state.token = null;
|
||||
state.user = null;
|
||||
state.apps = [];
|
||||
localStorage.removeItem('shorebird_token');
|
||||
document.getElementById('appContainer').classList.remove('active');
|
||||
document.getElementById('loginPage').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
state.user = await api.get('/users/me');
|
||||
document.getElementById('currentUserEmail').textContent = state.user.email || '—';
|
||||
} catch (e) {
|
||||
// User endpoint might not be available
|
||||
state.user = { email: 'admin' };
|
||||
}
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
document.getElementById('loginPage').style.display = 'none';
|
||||
document.getElementById('appContainer').classList.add('active');
|
||||
}
|
||||
|
||||
async function register(name, email, password) {
|
||||
const data = await api.request('POST', '/auth/register', { name, email, password });
|
||||
state.token = data.token;
|
||||
localStorage.setItem('shorebird_token', data.token);
|
||||
await loadCurrentUser();
|
||||
closeModal('registerOverlay');
|
||||
showApp();
|
||||
navigate('dashboard');
|
||||
showToast('Account created successfully', 'success');
|
||||
}
|
||||
|
||||
// ---- Dashboard ----
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const appsResp = await api.get('/apps');
|
||||
state.apps = appsResp.apps || [];
|
||||
document.getElementById('statApps').textContent = state.apps.length;
|
||||
document.getElementById('statPatches').textContent = '—';
|
||||
|
||||
// Try to get users
|
||||
try {
|
||||
const usersResp = await api.get('/admin/users');
|
||||
state.users = usersResp.users || [];
|
||||
document.getElementById('statUsers').textContent = state.users.length;
|
||||
} catch {
|
||||
document.getElementById('statUsers').textContent = '—';
|
||||
}
|
||||
|
||||
// Try to get organizations
|
||||
try {
|
||||
const orgsResp = await api.get('/organizations');
|
||||
const orgs = orgsResp.organizations || [];
|
||||
document.getElementById('statOrganizations').textContent = orgs.length;
|
||||
} catch {
|
||||
document.getElementById('statOrganizations').textContent = '—';
|
||||
}
|
||||
|
||||
// Recent apps table
|
||||
const tableDiv = document.getElementById('recentAppsTable');
|
||||
if (state.apps.length === 0) {
|
||||
tableDiv.innerHTML = '<div class="empty-state"><div class="empty-icon">📱</div><p>No apps yet. Create your first app to get started.</p></div>';
|
||||
} else {
|
||||
const recent = state.apps.slice(0, 5);
|
||||
tableDiv.innerHTML = renderTable(
|
||||
['App Name', 'App ID', 'Created'],
|
||||
recent.map(a => [
|
||||
a.display_name,
|
||||
`<code class="code">${a.app_id}</code>`,
|
||||
new Date(a.created_at).toLocaleDateString(),
|
||||
])
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Failed to load dashboard', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Apps ----
|
||||
async function loadApps() {
|
||||
try {
|
||||
const resp = await api.get('/apps');
|
||||
state.apps = resp.apps || [];
|
||||
const tableDiv = document.getElementById('appsTable');
|
||||
|
||||
if (state.apps.length === 0) {
|
||||
tableDiv.innerHTML = '<div class="empty-state"><div class="empty-icon">📱</div><p>No apps yet. Create your first app.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
tableDiv.innerHTML = renderTable(
|
||||
['App Name', 'App ID', 'Created', 'Actions'],
|
||||
state.apps.map(a => [
|
||||
a.display_name,
|
||||
`<code class="code">${a.app_id}</code>`,
|
||||
new Date(a.created_at).toLocaleDateString(),
|
||||
`<button class="btn btn-ghost" onclick="copyToClipboard('${a.app_id}')" title="Copy App ID">📋</button>
|
||||
<button class="btn btn-ghost" onclick="confirmDeleteApp('${a.app_id}','${escapeHtml(a.display_name)}')" title="Delete App">🗑️</button>`,
|
||||
])
|
||||
);
|
||||
} catch (e) {
|
||||
showToast('Failed to load apps', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function createApp(displayName) {
|
||||
try {
|
||||
await api.post('/apps', { display_name: displayName, organization_id: 0 });
|
||||
closeModal('createAppOverlay');
|
||||
showToast(`App "${displayName}" created`, 'success');
|
||||
loadApps();
|
||||
loadDashboard();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Failed to create app', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteApp(appId, name) {
|
||||
confirmAction('Delete App', `Are you sure you want to delete "${name}"? This action cannot be undone.`, async () => {
|
||||
try {
|
||||
await api.del(`/apps/${appId}`);
|
||||
showToast(`App "${name}" deleted`, 'success');
|
||||
loadApps();
|
||||
loadDashboard();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Failed to delete app', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateAppModal() {
|
||||
document.getElementById('appDisplayName').value = '';
|
||||
openModal('createAppOverlay');
|
||||
}
|
||||
|
||||
// ---- Users ----
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const resp = await api.get('/admin/users');
|
||||
state.users = resp.users || [];
|
||||
const tableDiv = document.getElementById('usersTable');
|
||||
|
||||
if (state.users.length === 0) {
|
||||
tableDiv.innerHTML = '<div class="empty-state"><div class="empty-icon">👥</div><p>No users found.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
tableDiv.innerHTML = renderTable(
|
||||
['ID', 'Email', 'Name', 'Role', 'Created'],
|
||||
state.users.map(u => [
|
||||
u.id,
|
||||
u.email,
|
||||
u.name || '—',
|
||||
u.role ? `<span class="badge badge-info">${u.role}</span>` : `<span class="badge">member</span>`,
|
||||
u.created_at ? new Date(u.created_at).toLocaleDateString() : '—',
|
||||
])
|
||||
);
|
||||
} catch (e) {
|
||||
// If admin/users endpoint is not available, show a message
|
||||
document.getElementById('usersTable').innerHTML =
|
||||
'<div class="empty-state"><div class="empty-icon">👥</div><p>User management API not available. Check server configuration.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(name, email, password) {
|
||||
try {
|
||||
await api.request('POST', '/auth/register', { name, email, password });
|
||||
closeModal('createUserOverlay');
|
||||
showToast(`User "${email}" created`, 'success');
|
||||
loadUsers();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Failed to create user', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateUserModal() {
|
||||
document.getElementById('newUserName').value = '';
|
||||
document.getElementById('newUserEmail').value = '';
|
||||
document.getElementById('newUserPassword').value = '';
|
||||
openModal('createUserOverlay');
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
async function loadSettings() {
|
||||
const infoDiv = document.getElementById('serverInfo');
|
||||
infoDiv.innerHTML = `
|
||||
<div class="info-item">
|
||||
<div class="info-label">Server</div>
|
||||
<div class="info-value">Shorebird Self-Hosted v1.0</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">API Base</div>
|
||||
<div class="info-value"><code class="code">${window.location.origin}/api/v1</code></div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Auth Endpoint</div>
|
||||
<div class="info-value"><code class="code">${window.location.origin}/auth</code></div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Storage</div>
|
||||
<div class="info-value">MinIO / S3-compatible</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Database</div>
|
||||
<div class="info-value">PostgreSQL</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">UI Version</div>
|
||||
<div class="info-value">1.0.0</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show current API token
|
||||
document.getElementById('apiTokenDisplay').value = state.token || 'Not authenticated';
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
function renderTable(headers, rows) {
|
||||
let html = '<table><thead><tr>';
|
||||
headers.forEach(h => { html += `<th>${h}</th>`; });
|
||||
html += '</tr></thead><tbody>';
|
||||
rows.forEach(row => {
|
||||
html += '<tr>';
|
||||
row.forEach(cell => { html += `<td>${cell}</td>`; });
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast('Copied to clipboard', 'success');
|
||||
}).catch(() => {
|
||||
// Fallback
|
||||
const input = document.createElement('input');
|
||||
input.value = text;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
showToast('Copied to clipboard', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Event Listeners ----
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Login form
|
||||
document.getElementById('loginForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('loginEmail').value;
|
||||
const password = document.getElementById('loginPassword').value;
|
||||
const errorDiv = document.getElementById('loginError');
|
||||
errorDiv.style.display = 'none';
|
||||
try {
|
||||
await login(email, password);
|
||||
} catch (err) {
|
||||
errorDiv.textContent = err.message || 'Login failed. Check your credentials.';
|
||||
errorDiv.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// Register form (modal)
|
||||
document.getElementById('registerForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('regName').value;
|
||||
const email = document.getElementById('regEmail').value;
|
||||
const password = document.getElementById('regPassword').value;
|
||||
try {
|
||||
await register(name, email, password);
|
||||
} catch (err) {
|
||||
showToast(err.message || 'Registration failed', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Show register modal
|
||||
document.getElementById('showRegister').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
document.getElementById('regName').value = '';
|
||||
document.getElementById('regEmail').value = '';
|
||||
document.getElementById('regPassword').value = '';
|
||||
openModal('registerOverlay');
|
||||
});
|
||||
|
||||
function closeRegister() { closeModal('registerOverlay'); }
|
||||
window.closeRegister = closeRegister;
|
||||
|
||||
// Create app form
|
||||
document.getElementById('createAppForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('appDisplayName').value;
|
||||
await createApp(name);
|
||||
});
|
||||
|
||||
// Create user form
|
||||
document.getElementById('createUserForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('newUserName').value;
|
||||
const email = document.getElementById('newUserEmail').value;
|
||||
const password = document.getElementById('newUserPassword').value;
|
||||
await createUser(name, email, password);
|
||||
});
|
||||
|
||||
// Sidebar navigation
|
||||
document.querySelectorAll('#sidebarNav a').forEach(link => {
|
||||
link.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const page = link.dataset.page;
|
||||
navigate(page);
|
||||
window.location.hash = page;
|
||||
});
|
||||
});
|
||||
|
||||
// Logout
|
||||
document.getElementById('logoutBtn').addEventListener('click', () => {
|
||||
logout();
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
// Close modals on overlay click
|
||||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||
overlay.addEventListener('click', e => {
|
||||
if (e.target === overlay) closeModal(overlay.id);
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-login if token exists
|
||||
if (state.token) {
|
||||
loadCurrentUser().then(() => {
|
||||
showApp();
|
||||
const page = window.location.hash.replace('#', '') || 'dashboard';
|
||||
navigate(page);
|
||||
}).catch(() => {
|
||||
logout();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Export functions for inline onclick handlers
|
||||
window.openCreateAppModal = openCreateAppModal;
|
||||
window.openCreateUserModal = openCreateUserModal;
|
||||
window.confirmDeleteApp = confirmDeleteApp;
|
||||
window.copyToClipboard = copyToClipboard;
|
||||
window.closeModal = closeModal;
|
||||
Reference in New Issue
Block a user