initial commit
This commit is contained in:
commit
ebd5bb09fd
30 changed files with 1920 additions and 0 deletions
245
web/src/App.jsx
Normal file
245
web/src/App.jsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { escapeHTML } from './App.utils';
|
||||
|
||||
const state = {
|
||||
tenants: [],
|
||||
applications: [],
|
||||
databases: [],
|
||||
selectedTenant: '',
|
||||
isSystemAdministrator: false,
|
||||
};
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const $$ = selector => [...document.querySelectorAll(selector)];
|
||||
const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'': '''
|
||||
}[c]));
|
||||
|
||||
const roleOptions = roles => roles
|
||||
.map(item => `<option value="${escapeHTML(item.value)}">${escapeHTML(item.label)}</option>`)
|
||||
.join('');
|
||||
const $$Q = '[data-action]';
|
||||
|
||||
const notice = (message, error = false) => {
|
||||
const el = $('#notice');
|
||||
el.textContent = message;
|
||||
el.hidden = false;
|
||||
el.style.borderColor = error ? 'var(--danger)' : 'var(--accent)';
|
||||
setTimeout(() => el.hidden = true, 5000);
|
||||
};
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
||||
});
|
||||
if (response.status === 401) {
|
||||
location.href = '/login';
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
const body = response.status === 204 ? null : await response.json();
|
||||
if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`);
|
||||
return body;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const data = await api('/api/v1/dashboard');
|
||||
state.tenants = data.tenants || [];
|
||||
state.applications = data.applications || [];
|
||||
state.databases = data.databases || [];
|
||||
state.isSystemAdministrator = data.is_system_administrator || false;
|
||||
state.selectedTenant = data.selected_tenant || state.tenants[0]?.id || '';
|
||||
render();
|
||||
updateTenantVisibility();
|
||||
}
|
||||
|
||||
function updateTenantVisibility() {
|
||||
$$Q('[data-tenant-action]').forEach(el => {
|
||||
const action = el.dataset.tenantAction;
|
||||
if (action === 'system-only' && !state.isSystemAdministrator) {
|
||||
el.hidden = true;
|
||||
} else {
|
||||
el.hidden = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tenantID = state.selectedTenant;
|
||||
const selectedTenant = state.tenants.find(t => t.id === tenantID);
|
||||
const canManageDatabases = selectedTenant ? selectedTenant.permissions && selectedTenant.permissions.includes('databases.manage') : false;
|
||||
const tenantApps = state.applications.filter(a => a.tenant_id === tenantID);
|
||||
const tenantDBs = state.databases.filter(d => d.tenant_id === tenantID);
|
||||
|
||||
$('#tenant-count').textContent = state.tenants.length;
|
||||
$('#app-count').textContent = tenantApps.length;
|
||||
$('#db-count').textContent = tenantDBs.length;
|
||||
|
||||
$('#tenant-context').innerHTML = state.tenants.map(item => `
|
||||
<option value="${escapeHTML(item.id)}" ${item.id === tenantID ? 'selected' : ''}>
|
||||
${escapeHTML(item.name)}
|
||||
</option>
|
||||
`).join('') || '<option>No tenants yet</option>';
|
||||
|
||||
$('#tenant-list').innerHTML = state.tenants.map(item => `
|
||||
<article class="card">
|
||||
<span class="badge">${escapeHTML(item.role)}</span>
|
||||
<h3>${escapeHTML(item.name)}</h3>
|
||||
<p>${escapeHTML(item.slug)}</p>
|
||||
<div class="meta"><span>Tenant</span><code>${escapeHTML(item.id.slice(0, 8))}</code></div>
|
||||
</article>
|
||||
`).join('') || empty('No tenants yet');
|
||||
|
||||
$('#app-list').innerHTML = tenantApps.map(item => `
|
||||
<article class="card">
|
||||
<span class="badge">${escapeHTML(item.tenant_slug)}</span>
|
||||
<h3>${escapeHTML(item.name)}</h3>
|
||||
<p>${escapeHTML(item.description || 'No description')}</p>
|
||||
<div class="meta">
|
||||
<span>${item.client_ready ? 'Client login ready' : 'Client login not configured'}</span>
|
||||
<code>${escapeHTML(item.client_id)}</code>
|
||||
</div>
|
||||
${canManageDatabases ? `<button class="card-action" data-client="${escapeHTML(item.id)}">Manage databases</button>` : ''}
|
||||
</article>
|
||||
`).join('') || empty('No applications yet');
|
||||
|
||||
$('#db-list').innerHTML = tenantDBs.map(item => `
|
||||
<article class="card">
|
||||
<h3>${escapeHTML(item.name)}</h3>
|
||||
<p>${escapeHTML(item.connection_string ? 'Connected' : 'Not connected')}</p>
|
||||
<div class="meta">
|
||||
<span>Tenant ID: ${escapeHTML(item.tenant_id)}</span>
|
||||
${item.migration_status ? `<span>Migrations: ${escapeHTML(item.migration_status)}</span>` : ''}
|
||||
</div>
|
||||
${canManageDatabases ? `<button class="card-action" data-db="${escapeHTML(item.id)}">Run migrations</button>` : ''}
|
||||
</article>
|
||||
`).join('') || empty('No databases yet');
|
||||
}
|
||||
|
||||
const empty = text => `<article class="card"><p>${text}</p></article>`;
|
||||
|
||||
function formJSON(form) {
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
return data;
|
||||
}
|
||||
|
||||
$$Q('[data-dialog]').forEach(button => button.addEventListener('click', () => $('#' + button.dataset.dialog).showModal()));
|
||||
$$('.close').forEach(button => button.addEventListener('click', () => button.closest('dialog').close()));
|
||||
$$('.tab').forEach(button => button.addEventListener('click', () => {
|
||||
$$('.tab,.panel').forEach(el => el.classList.remove('active'));
|
||||
button.classList.add('active');
|
||||
$('#' + button.dataset.panel).classList.add('active')
|
||||
}));
|
||||
|
||||
$('#tenant-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await api('/api/v1/tenants', { method: 'POST', body: JSON.stringify(formJSON(event.target)) });
|
||||
event.target.closest('dialog').close();
|
||||
event.target.reset();
|
||||
notice('Tenant created');
|
||||
await load();
|
||||
} catch (error) {
|
||||
notice(error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
$('#app-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await api('/api/v1/applications', { method: 'POST', body: JSON.stringify(formJSON(event.target)) });
|
||||
event.target.closest('dialog').close();
|
||||
event.target.reset();
|
||||
notice('Application registered');
|
||||
await load();
|
||||
} catch (error) {
|
||||
notice(error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
$('#db-form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await api('/api/v1/databases', { method: 'POST', body: JSON.stringify(formJSON(event.target)) });
|
||||
event.target.closest('dialog').close();
|
||||
event.target.reset();
|
||||
notice('Database connection added');
|
||||
await load();
|
||||
} catch (error) {
|
||||
notice(error.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
$$('[data-tenant-application-action]').forEach(element => element.hidden = !state.isSystemAdministrator);
|
||||
$$('[data-tenant-db-action]').forEach(element => element.hidden = !state.isSystemAdministrator);
|
||||
|
||||
$$('.tenant-select').forEach(select => {
|
||||
select.innerHTML = state.tenants.map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('');
|
||||
select.value = tenantID;
|
||||
});
|
||||
|
||||
$$('.app-select').forEach(select => select.innerHTML = state.applications.filter(a => a.tenant_id === tenantID).map(item => `<option value="${escapeHTML(item.id)}">${escapeHTML(item.name)}</option>`).join('') || '<option>No applications</option>');
|
||||
|
||||
$$('[data-db-action]').forEach(element => {
|
||||
const action = element.dataset.dbAction;
|
||||
if (action === 'run-migration' && !state.isSystemAdministrator) {
|
||||
element.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', async event => {
|
||||
const dbID = event.target.dataset.db;
|
||||
if (dbID) {
|
||||
if (confirm('Run migrations for this database?')) {
|
||||
try {
|
||||
await api(`/api/v1/databases/${dbID}/migrations`, { method: 'POST' });
|
||||
notice('Migrations executed');
|
||||
await load();
|
||||
} catch (error) {
|
||||
notice(error.message, true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
const clientID = event.target.dataset.client;
|
||||
if (clientID) {
|
||||
const app = state.applications.find(item => item.id === clientID), form = $('#client-form');
|
||||
form.application_id.value = app.id;
|
||||
form.client_id.value = app.client_id;
|
||||
form.redirect_uris.value = app.redirect_uris.join('\n');
|
||||
$('#client-dialog').showModal();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
$('#copy-secret').addEventListener('click', () => {
|
||||
const secret = $('#new-secret').textContent;
|
||||
navigator.clipboard.writeText(secret).then(() => notice('Secret copied'));
|
||||
});
|
||||
|
||||
const theme = $('#theme'), mode = $('#mode');
|
||||
theme.value = localStorage.getItem('gl-theme') || 'tokyo';
|
||||
document.documentElement.dataset.theme = theme.value;
|
||||
document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark';
|
||||
theme.addEventListener('change', () => {
|
||||
document.documentElement.dataset.theme = theme.value;
|
||||
localStorage.setItem('gl-theme', theme.value);
|
||||
});
|
||||
mode.addEventListener('click', () => {
|
||||
const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.mode = next;
|
||||
localStorage.setItem('gl-mode', next);
|
||||
});
|
||||
|
||||
$('#tenant-context').addEventListener('change', event => {
|
||||
state.selectedTenant = event.target.value;
|
||||
render();
|
||||
updateTenantVisibility();
|
||||
});
|
||||
|
||||
load().catch(error => notice(error.message, true));
|
||||
1
web/src/App.utils.js
Normal file
1
web/src/App.utils.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { escapeHTML };
|
||||
585
web/src/index.css
Normal file
585
web/src/index.css
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
:root{
|
||||
--bg:#0d0d17;
|
||||
--surface:#1a1a2e;
|
||||
--surface2:#16162e;
|
||||
--text:#e0e0e0;
|
||||
--muted:#8888a0;
|
||||
--accent:#bb86fc;
|
||||
--accent2:#03dac6;
|
||||
--good:#4caf50;
|
||||
--danger:#f44336;
|
||||
--border:#3d3d5d;
|
||||
--shadow:rgba(0,0,0,.4);
|
||||
}
|
||||
[data-mode=light]{--bg:#f5f5f5;--surface:#fff;--surface2:#f0f0f5;--text:#1a1a2e;--muted:#424266;--accent:#0d47a1;--accent2:#1565c0;--good:#2e7d32;--danger:#c62828;--border:#bdbdbd;--shadow:rgba(0,0,0,.15)}
|
||||
[data-mode=dark]{--bg:#0d0d17;--surface:#1a1a2e;--surface2:#16162e;--text:#e0e0e0;--muted:#8888a0;--accent:#bb86fc;--accent2:#03dac6;--good:#4caf50;--danger:#f44336;--border:#3d3d5d;--shadow:rgba(0,0,0,.4)}
|
||||
*{
|
||||
box-sizing:border-box;
|
||||
margin:0;
|
||||
padding:0;
|
||||
}
|
||||
html{
|
||||
background:var(--bg);
|
||||
color:var(--text);
|
||||
font-family:Inter,ui-sans-serif,system-ui,sans-serif;
|
||||
}
|
||||
body{
|
||||
min-height:100vh;
|
||||
background:
|
||||
radial-gradient(circle at 70% -10%, color-mix(in srgb,var(--accent2) 12%,transparent) 25%, transparent 40%),
|
||||
var(--bg);
|
||||
}
|
||||
a{
|
||||
color:var(--accent);
|
||||
text-decoration:none;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select{
|
||||
font:inherit;
|
||||
}
|
||||
.noise{
|
||||
position:fixed;
|
||||
inset:0;
|
||||
pointer-events:none;
|
||||
opacity:.03;
|
||||
background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='4'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
header{
|
||||
height:68px;
|
||||
border-bottom:1px solid var(--border);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
padding:0 24px;
|
||||
background:color-mix(in srgb,var(--bg) 88%,transparent);
|
||||
backdrop-filter:blur(14px);
|
||||
position:sticky;
|
||||
top:0;
|
||||
z-index:20;
|
||||
}
|
||||
.brand{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
color:var(--text);
|
||||
font-weight:750;
|
||||
text-decoration:none;
|
||||
letter-spacing:-.02em;
|
||||
}
|
||||
.brand-mark{
|
||||
display:grid;
|
||||
place-items:center;
|
||||
width:32px;
|
||||
height:32px;
|
||||
border:1px solid var(--accent);
|
||||
color:var(--accent);
|
||||
font:700 11px ui-monospace,monospace;
|
||||
clip-path:polygon(10% 0,100% 0,100% 75%,75% 100%,0 100%,0 10%);
|
||||
}
|
||||
nav{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:16px;
|
||||
}
|
||||
nav a{
|
||||
color:var(--muted);
|
||||
text-decoration:none;
|
||||
font-size:14px;
|
||||
}
|
||||
nav form{
|
||||
margin:0;
|
||||
}
|
||||
.compact{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:6px;
|
||||
font-size:11px;
|
||||
color:var(--muted);
|
||||
}
|
||||
select,
|
||||
input,
|
||||
textarea{
|
||||
color:var(--text);
|
||||
background:var(--surface2);
|
||||
border:1px solid var(--border);
|
||||
border-radius:6px;
|
||||
padding:8px 12px;
|
||||
outline:none;
|
||||
}
|
||||
select:focus,
|
||||
input:focus,
|
||||
textarea:focus{
|
||||
border-color:var(--accent);
|
||||
box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 15%,transparent);
|
||||
}
|
||||
select:disabled,
|
||||
input:disabled{
|
||||
background:var(--border);
|
||||
color:var(--muted);
|
||||
}
|
||||
.icon-button,
|
||||
.ghost{
|
||||
padding:6px 10px;
|
||||
background:transparent;
|
||||
border:1px solid var(--border);
|
||||
color:var(--text);
|
||||
border-radius:6px;
|
||||
}
|
||||
.identity{
|
||||
font-size:12px;
|
||||
color:var(--accent);
|
||||
display:inline-flex;
|
||||
align-items-center;
|
||||
gap:6px;
|
||||
}
|
||||
main{
|
||||
width:min(1400px,92vw);
|
||||
margin:80px auto 60px;
|
||||
}
|
||||
.hero{
|
||||
min-height:280px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:40px;
|
||||
border-bottom:1px solid var(--border);
|
||||
}
|
||||
.eyebrow{
|
||||
font:700 10px ui-monospace,monospace;
|
||||
letter-spacing:.14em;
|
||||
color:var(--accent);
|
||||
margin:0 0 12px;
|
||||
}
|
||||
.hero h1{
|
||||
font-size:clamp(36px,4vw,56px);
|
||||
line-height:.96;
|
||||
letter-spacing:-.04em;
|
||||
margin:0;
|
||||
}
|
||||
.hero h1 span{
|
||||
color:var(--accent2);
|
||||
}
|
||||
.lede{
|
||||
max-width:560px;
|
||||
color:color-mix(in srgb,var(--text) 65%,transparent);
|
||||
font-size:15px;
|
||||
line-height:1.6;
|
||||
}
|
||||
.status-card{
|
||||
min-width:280px;
|
||||
background:linear-gradient(145deg,var(--surface),var(--surface2));
|
||||
border:1px solid var(--border);
|
||||
border-radius:12px;
|
||||
padding:18px;
|
||||
box-shadow:0 16px 48px var(--shadow);
|
||||
display:grid;
|
||||
grid-template-columns:auto 1fr;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
}
|
||||
.status-card span{
|
||||
display:block;
|
||||
color:var(--good);
|
||||
font-size:11px;
|
||||
margin-top:3px;
|
||||
}
|
||||
.status-card code{
|
||||
grid-column:1/-1;
|
||||
background:var(--bg);
|
||||
padding:10px;
|
||||
border-radius:6px;
|
||||
color:var(--muted);
|
||||
}
|
||||
.pulse{
|
||||
width:8px;
|
||||
height:8px;
|
||||
border-radius:50%;
|
||||
background:var(--good);
|
||||
box-shadow:0 0 0 5px color-mix(in srgb,var(--good) 12%,transparent);
|
||||
}
|
||||
.stats{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(auto-fit,minmax(140px,1fr));
|
||||
gap:2px;
|
||||
background:var(--border);
|
||||
border:1px solid var(--border);
|
||||
margin:24px 0;
|
||||
overflow:hidden;
|
||||
border-radius:10px;
|
||||
}
|
||||
.stats article{
|
||||
background:var(--surface);
|
||||
padding:16px 20px;
|
||||
}
|
||||
.stats span{
|
||||
display:block;
|
||||
color:var(--muted);
|
||||
font-size:11px;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:.08em;
|
||||
}
|
||||
.stats strong{
|
||||
display:block;
|
||||
font-size:24px;
|
||||
margin-top:4px;
|
||||
}
|
||||
.workspace{
|
||||
display:grid;
|
||||
grid-template-columns:180px 1fr;
|
||||
gap:24px;
|
||||
}
|
||||
.workspace aside{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:4px;
|
||||
}
|
||||
.tab{
|
||||
border:0;
|
||||
border-left:2px solid transparent;
|
||||
background:transparent;
|
||||
color:var(--muted);
|
||||
padding:10px;
|
||||
text-align:left;
|
||||
font:600 11px ui-monospace,monospace;
|
||||
}
|
||||
.tab span{
|
||||
font-family:Inter,system-ui;
|
||||
margin-left:6px;
|
||||
}
|
||||
.tab.active{
|
||||
color:var(--accent);
|
||||
border-color:var(--accent);
|
||||
background:color-mix(in srgb,var(--accent) 7%,transparent);
|
||||
}
|
||||
.panel{
|
||||
display:none;
|
||||
}
|
||||
.panel.active{
|
||||
display:block;
|
||||
}
|
||||
.panel-head{
|
||||
display:flex;
|
||||
align-items:flex-end;
|
||||
justify-content:space-between;
|
||||
margin-bottom:16px;
|
||||
}
|
||||
.panel h2{
|
||||
font-size:28px;
|
||||
letter-spacing:-.03em;
|
||||
margin:0;
|
||||
}
|
||||
.panel-head button,
|
||||
.card-action{
|
||||
background:var(--accent);
|
||||
color:var(--bg);
|
||||
font-weight:700;
|
||||
border:0;
|
||||
padding:9px 14px;
|
||||
border-radius:6px;
|
||||
cursor:pointer;
|
||||
}
|
||||
.card-grid{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
|
||||
gap:12px;
|
||||
}
|
||||
.card{
|
||||
background:var(--surface);
|
||||
border:1px solid var(--border);
|
||||
border-radius:8px;
|
||||
padding:16px;
|
||||
}
|
||||
.card h3{
|
||||
margin:0 0 4px;
|
||||
}
|
||||
.card p{
|
||||
color:color-mix(in srgb,var(--text) 55%,transparent);
|
||||
min-height:20px;
|
||||
}
|
||||
.card-action{
|
||||
width:100%;
|
||||
margin-top:12px;
|
||||
background:color-mix(in srgb,var(--accent) 12%,var(--surface));
|
||||
color:var(--accent);
|
||||
border:1px solid color-mix(in srgb,var(--accent) 40%,var(--border));
|
||||
}
|
||||
.badge{
|
||||
font:700 9px ui-monospace,monospace;
|
||||
text-transform:uppercase;
|
||||
color:var(--accent);
|
||||
background:color-mix(in srgb,var(--accent) 8%,transparent);
|
||||
padding:4px 6px;
|
||||
border-radius:4px;
|
||||
}
|
||||
.meta{
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
margin-top:12px;
|
||||
color:var(--muted);
|
||||
font-size:10px;
|
||||
}
|
||||
.table-wr{
|
||||
width:100%;
|
||||
border-collapse:collapse;
|
||||
}
|
||||
.table-wr th,
|
||||
.table-wr td{
|
||||
padding:8px 4px;
|
||||
border-bottom:1px solid var(--border);
|
||||
font-size:11px;
|
||||
}
|
||||
.table-wr th{
|
||||
color:var(--muted);
|
||||
text-align:left;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:.06em;
|
||||
}
|
||||
.empty{
|
||||
padding:20px;
|
||||
color:var(--muted);
|
||||
}
|
||||
.dialog{
|
||||
color:var(--text);
|
||||
background:var(--surface);
|
||||
border:1px solid var(--border);
|
||||
border-radius:10px;
|
||||
box-shadow:0 24px 80px rgba(0,0,0,.55);
|
||||
width:min(480px,92vw);
|
||||
padding:20px;
|
||||
}
|
||||
dialog::backdrop{
|
||||
background:rgba(8,8,15,.7);
|
||||
backdrop-filter:blur(3px);
|
||||
}
|
||||
dialog form{
|
||||
display:grid;
|
||||
gap:12px;
|
||||
}
|
||||
dialog form .dialog-head{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
}
|
||||
dialog form .dialog-head h3{
|
||||
margin:0;
|
||||
font-size:20px;
|
||||
}
|
||||
.close{
|
||||
border:0;
|
||||
background:transparent;
|
||||
color:var(--muted);
|
||||
font-size:24px;
|
||||
cursor:pointer;
|
||||
}
|
||||
dialog label{
|
||||
display:grid;
|
||||
gap:5px;
|
||||
font-size:12px;
|
||||
color:var(--muted);
|
||||
}
|
||||
textarea{
|
||||
resize:vertical;
|
||||
}
|
||||
.client-terminal{
|
||||
width:min(920px,92vw);
|
||||
margin:24px 0;
|
||||
position:relative;
|
||||
background:color-mix(in srgb,var(--surface) 93%,black);
|
||||
border:1px solid var(--border);
|
||||
border-radius:8px;
|
||||
box-shadow:0 32px 100px rgba(0,0,0,.55);
|
||||
overflow:hidden;
|
||||
font-family:ui-monospace,SFMono-Regular,monospace;
|
||||
}
|
||||
.client-terminal .terminal-bar{
|
||||
height:36px;
|
||||
background:var(--surface2);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:6px;
|
||||
padding:0 12px;
|
||||
}
|
||||
.client-terminal .terminal-bar span{
|
||||
width:9px;
|
||||
height:9px;
|
||||
border-radius:50%;
|
||||
background:var(--danger);
|
||||
}
|
||||
.client-terminal .terminal-bar span:nth-child(2){
|
||||
background:#e0af68;
|
||||
}
|
||||
.client-terminal .terminal-bar span:nth-child(3){
|
||||
background:var(--good);
|
||||
}
|
||||
.client-terminal .terminal-bar strong{
|
||||
position:absolute;
|
||||
left:50%;
|
||||
transform:translateX(-50%);
|
||||
color:var(--muted);
|
||||
font-size:11px;
|
||||
}
|
||||
.client-terminal .terminal-body{
|
||||
padding:24px;
|
||||
}
|
||||
.client-terminal .ascii{
|
||||
color:var(--accent);
|
||||
font-size:clamp(5px,.8vw,10px);
|
||||
line-height:1.2;
|
||||
overflow:hidden;
|
||||
}
|
||||
.client-terminal .prompt{
|
||||
color:var(--good);
|
||||
}
|
||||
.client-terminal .muted{
|
||||
color:var(--muted);
|
||||
max-width:550px;
|
||||
line-height:1.4;
|
||||
}
|
||||
.login-page{
|
||||
display:grid;
|
||||
place-items:center;
|
||||
min-height:100vh;
|
||||
overflow:hidden;
|
||||
}
|
||||
.login-glow{
|
||||
position:fixed;
|
||||
width:500px;
|
||||
height:500px;
|
||||
border-radius:50%;
|
||||
background:var(--accent2);
|
||||
filter:blur(160px);
|
||||
opacity:.12;
|
||||
}
|
||||
.terminal{
|
||||
width:min(920px,92vw);
|
||||
margin:24px;
|
||||
position:relative;
|
||||
background:color-mix(in srgb,var(--surface) 93%,black);
|
||||
border:1px solid var(--border);
|
||||
border-radius:8px;
|
||||
box-shadow:0 32px 100px rgba(0,0,0,.55);
|
||||
overflow:hidden;
|
||||
font-family:ui-monospace,SFMono-Regular,monospace;
|
||||
}
|
||||
.terminal .terminal-bar{
|
||||
height:36px;
|
||||
background:var(--surface2);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:6px;
|
||||
padding:0 12px;
|
||||
}
|
||||
.terminal .terminal-bar span{
|
||||
width:9px;
|
||||
height:9px;
|
||||
border-radius:50%;
|
||||
background:var(--danger);
|
||||
}
|
||||
.terminal .terminal-bar span:nth-child(2){
|
||||
background:#e0af68;
|
||||
}
|
||||
.terminal .terminal-bar span:nth-child(3){
|
||||
background:var(--good);
|
||||
}
|
||||
.terminal .terminal-bar strong{
|
||||
position:absolute;
|
||||
left:50%;
|
||||
transform:translateX(-50%);
|
||||
color:var(--muted);
|
||||
font-size:11px;
|
||||
}
|
||||
.terminal .terminal-body{
|
||||
padding:24px;
|
||||
}
|
||||
.terminal .ascii{
|
||||
color:var(--accent);
|
||||
font-size:clamp(5px, .8vw, 10px);
|
||||
line-height:1.2;
|
||||
overflow:hidden;
|
||||
}
|
||||
.terminal .prompt{
|
||||
color:var(--good);
|
||||
}
|
||||
.terminal .muted{
|
||||
color:var(--muted);
|
||||
max-width:600px;
|
||||
line-height:1.4;
|
||||
}
|
||||
.password-login{
|
||||
display:grid;
|
||||
grid-template-columns:1fr 1fr auto;
|
||||
gap:8px;
|
||||
align-items:end;
|
||||
margin:20px 0;
|
||||
}
|
||||
.password-login label{
|
||||
display:grid;
|
||||
gap:4px;
|
||||
color:var(--muted);
|
||||
font-size:11px;
|
||||
}
|
||||
.password-login button{
|
||||
height:36px;
|
||||
border:0;
|
||||
border-radius:6px;
|
||||
background:var(--accent);
|
||||
color:var(--bg);
|
||||
font-weight:700;
|
||||
padding:0 16px;
|
||||
}
|
||||
.login-divider{
|
||||
color:var(--muted);
|
||||
font-size:10px;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:.1em;
|
||||
}
|
||||
.installer-form{
|
||||
display:grid;
|
||||
gap:12px;
|
||||
margin-top:20px;
|
||||
}
|
||||
.installer-form label{
|
||||
display:grid;
|
||||
gap:4px;
|
||||
color:var(--muted);
|
||||
font-size:11px;
|
||||
}
|
||||
.installer-form button{
|
||||
height:40px;
|
||||
border:0;
|
||||
border-radius:6px;
|
||||
background:var(--accent);
|
||||
color:var(--bg);
|
||||
font-weight:700;
|
||||
}
|
||||
.installer-form .seed-option{
|
||||
display:flex;
|
||||
gap:6px;
|
||||
align-items:center;
|
||||
}
|
||||
.installer h1{
|
||||
font-size:clamp(32px,5vw,52px);
|
||||
line-height:1;
|
||||
margin:8px 0;
|
||||
letter-spacing:-.04em;
|
||||
}
|
||||
.installer h1 span{
|
||||
color:var(--accent2);
|
||||
}
|
||||
@media(max-width:800px){
|
||||
header{padding:0 16px}
|
||||
nav .compact,.identity{display:none}
|
||||
.hero{padding:44px 0;display:block}
|
||||
.status-card{margin-top:30px;min-width:0}
|
||||
.stats{grid-template-columns:1fr 1fr}
|
||||
.workspace{grid-template-columns:1fr}
|
||||
.workspace aside{flex-direction:row;overflow:auto}
|
||||
.tab{white-space:nowrap;border-left:0;border-bottom:2px solid transparent}
|
||||
.card-grid{grid-template-columns:1fr}
|
||||
.ascii{font-size:5px}
|
||||
.terminal-body{padding:18px}
|
||||
.password-login{grid-template-columns:1fr}
|
||||
.password-login button{width:100%}
|
||||
}
|
||||
13
web/src/index.html
Normal file
13
web/src/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>go-mad - Tenant Database Manager</title>
|
||||
<link rel="stylesheet" href="/static/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/static/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
web/src/main.jsx
Normal file
9
web/src/main.jsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import './index.css';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue