feat(frontend): add a terminal theme and three-way theme switcher #3

Merged
Zakaria merged 4 commits from feat/console-themes-and-aws-focus into main 2026-10-23 14:41:07 +00:00
13 changed files with 219 additions and 209 deletions
+1
View File
@@ -27,6 +27,7 @@ RUN apk add --no-cache ca-certificates libstdc++
WORKDIR /app WORKDIR /app
COPY --from=api-build /app/server ./server COPY --from=api-build /app/server ./server
COPY --from=frontend-build /app/packages/frontend/dist ./public COPY --from=frontend-build /app/packages/frontend/dist ./public
COPY packages/api/public ./public
COPY TheCloud.png ./public/TheCloud.png COPY TheCloud.png ./public/TheCloud.png
COPY svgAsset/authentik.svg ./public/authentik.svg COPY svgAsset/authentik.svg ./public/authentik.svg
COPY aws-icons-svg ./public/aws-icons COPY aws-icons-svg ./public/aws-icons
-176
View File
@@ -1,176 +0,0 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in · TheCloud</title>
<style>
:root[data-theme=dark]{--frame:#07111d;--rail:#0a1626;--rfg:#e8f1fa;--rmut:#8aa3bc;--rbd:rgba(232,241,250,.14);--ac:#6cc4ff;--on:#06223a;
--pbg:#08131f;--grid:rgba(232,241,250,.08);--o1:#6cc4ff;--o2:#ffb547;--pfg:#f2f8ff;--pmut:#a9c3dc;--tile:rgba(12,26,43,.72);--tbd:rgba(232,241,250,.14);--ic:#8fd3ff;--hi:#ffb547;--glow:rgba(108,196,255,.16);--err:#ff9a9a;--errbg:#3a1822}
:root[data-theme=light]{--frame:#eef4fa;--rail:#0e2238;--rfg:#f2f8ff;--rmut:#9db6cf;--rbd:rgba(242,248,255,.18);--ac:#7fd0ff;--on:#06223a;
--pbg:#eaf2fa;--grid:rgba(14,34,56,.09);--o1:#0a6fc2;--o2:#c26a00;--pfg:#0e2238;--pmut:#4a627a;--tile:rgba(255,255,255,.8);--tbd:rgba(14,34,56,.14);--ic:#0a6fc2;--hi:#c26a00;--glow:rgba(10,111,194,.14);--err:#ffb0b0;--errbg:#3a1822}
:root[data-theme=cyber]{--frame:#010305;--rail:#02080a;--rfg:#4eff96;--rmut:#2f9a68;--rbd:rgba(78,255,150,.3);--ac:#ffea00;--on:#02080a;
--pbg:#02060a;--grid:rgba(78,255,150,.16);--o1:#4eff96;--o2:#ff10f0;--pfg:#4eff96;--pmut:#2fb877;--tile:rgba(5,13,16,.8);--tbd:rgba(78,255,150,.34);--ic:#4eff96;--hi:#ff10f0;--glow:rgba(78,255,150,.18);--err:#ff8da1;--errbg:#2a0a14}
*{box-sizing:border-box;margin:0}
html,body{min-height:100%}
body{background:var(--frame);color:var(--rfg);font-family:system-ui,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
body.fade,body.fade *{transition:background-color .5s,color .5s,border-color .5s!important}
.mono{font-family:ui-monospace,"SF Mono","Cascadia Code",Menlo,Consolas,monospace}
.defs{position:absolute;width:0;height:0;overflow:hidden}
.defs .hi{stroke:var(--hi)}.defs .hf{fill:var(--hi);stroke:none}
.frame{min-height:100vh;display:grid;grid-template-columns:400px minmax(0,1fr)}
/* rail */
.rail{background:var(--rail);padding:44px 48px 32px;display:flex;flex-direction:column;justify-content:space-between;gap:40px}
.brand{font-size:34px;font-weight:800;letter-spacing:-.045em;color:var(--ac);line-height:1;text-decoration:none;display:inline-block}
.signin{margin-top:clamp(40px,14vh,150px)}
.signin h2{font-size:15px;font-weight:600;margin-bottom:6px}
.lead{font-size:14px;line-height:1.55;color:var(--rmut)}
.dest{display:none;margin-top:16px;padding:9px 12px;border:1px dashed var(--rbd);border-radius:10px;font-size:12.5px;color:var(--rmut);overflow-wrap:anywhere}
.dest.on{display:block}.dest span{color:var(--rfg)}
.alert{display:none;margin-top:16px;padding:12px 14px;border:1px solid currentColor;border-radius:12px;background:var(--errbg);color:var(--err);font-size:14px;line-height:1.45}
.alert.on{display:block}
.btn{margin-top:22px;width:100%;display:flex;align-items:center;justify-content:center;gap:10px;padding:15px 22px;border:0;border-radius:999px;background:var(--ac);color:var(--on);font:700 15px system-ui,sans-serif;text-decoration:none;cursor:pointer;transition:transform .15s,box-shadow .2s,filter .2s}
.btn:hover{transform:translateY(-2px);box-shadow:0 12px 28px -10px var(--ac);filter:brightness(1.06)}
.btn:active{transform:none}
.btn:focus-visible,.tg button:focus-visible{outline:3px solid var(--rfg);outline-offset:3px}
.btn[aria-busy=true]{pointer-events:none;filter:saturate(.6)}
.sp{display:none;width:15px;height:15px;border:2.5px solid currentColor;border-right-color:transparent;border-radius:50%;animation:spin .7s linear infinite}
.btn[aria-busy=true] .sp{display:block}.btn[aria-busy=true] .sh{display:none}
@keyframes spin{to{transform:rotate(360deg)}}
.sso{margin-top:14px;font-size:12.5px;color:var(--rmut);text-align:center}
.foot{display:grid;gap:16px}
.tg{display:inline-flex;width:max-content;gap:2px;padding:3px;border:1px solid var(--rbd);border-radius:999px}
.tg button{width:30px;height:30px;border:0;border-radius:999px;background:none;color:var(--rmut);cursor:pointer;display:grid;place-items:center;transition:.25s}
.tg button:hover{color:var(--rfg)}
.tg button.on{background:var(--ac);color:var(--on)}
.legal{font-size:11.5px;line-height:1.55;color:var(--rmut)}
/* panel */
.dot{position:absolute;border-radius:50%;filter:blur(70px);opacity:.24;animation:dr 16s ease-in-out infinite;pointer-events:none}
.d1{width:460px;height:460px;top:-8%;left:-120px;background:var(--o1)}
.d2{width:540px;height:540px;bottom:-22%;right:-160px;background:var(--o2);animation-delay:-5s}
@keyframes dr{33%{transform:translate(60px,-40px) scale(1.1)}66%{transform:translate(-30px,50px) scale(.95)}}
.panel{position:relative;margin:20px;border-radius:28px;overflow:hidden;display:flex;align-items:center;justify-content:center;padding:56px 40px;
background:linear-gradient(var(--grid) 1px,transparent 1px) center/56px 56px,linear-gradient(90deg,var(--grid) 1px,transparent 1px) center/56px 56px,var(--pbg);color:var(--pfg);border:1px solid var(--tbd)}
.pglow{position:absolute;width:640px;height:640px;border-radius:50%;background:radial-gradient(circle,var(--glow),transparent 62%);transform:translate(-50%,-50%);pointer-events:none;opacity:0;transition:opacity .4s}
.inner{position:relative;width:100%;max-width:960px;text-align:center}
h1{font-family:ui-serif,"Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;font-weight:500;font-size:clamp(2.2rem,4.6vw,4rem);line-height:1.08;letter-spacing:-.02em;max-width:15em;margin:0 auto 48px;text-wrap:balance}
h1::after{content:"";display:inline-block;width:.08em;height:.85em;margin-left:.12em;background:var(--hi);vertical-align:-.05em;animation:bl 1.1s steps(1) infinite}
@keyframes bl{50%{opacity:0}}
.tiles{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:14px;list-style:none;padding:0}
.tile{padding:22px 8px 18px;border-radius:18px;background:var(--tile);border:1px solid var(--tbd);display:grid;align-content:start;justify-items:center;gap:12px;opacity:0;animation:up .6s cubic-bezier(.2,.7,.2,1) forwards;transition:transform .25s,border-color .25s,box-shadow .25s}
.tile:hover{transform:translateY(-5px);border-color:var(--hi);box-shadow:0 18px 36px -18px rgba(0,0,0,.5)}
.tile:nth-child(2){animation-delay:.06s}.tile:nth-child(3){animation-delay:.12s}.tile:nth-child(4){animation-delay:.18s}.tile:nth-child(5){animation-delay:.24s}.tile:nth-child(6){animation-delay:.3s}
@keyframes up{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
.ic{width:52px;height:52px;color:var(--ic)}
.tile b{display:block;font-size:14px;font-weight:650;line-height:1.25}
.tile small{display:block;margin-top:3px;font-size:11.5px;color:var(--pmut)}
.tag{margin-top:40px;font-size:16px;color:var(--pmut)}
@media (max-width:980px){.tiles{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media (max-width:900px){
.frame{grid-template-columns:1fr}
.rail{padding:28px 24px 24px;gap:26px}.signin{margin-top:22px}
.foot{order:3}
.panel{margin:0 14px 14px;padding:40px 18px;border-radius:22px}
h1{margin-bottom:30px}.tag{display:none}
}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important}.tile{opacity:1}.pglow{display:none}}
</style>
</head>
<body>
<svg class="defs" aria-hidden="true" focusable="false">
<symbol id="i-lambda" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 9h7l15 30"/><path class="hi" d="M24 25l-10 14"/></g></symbol>
<symbol id="i-s3" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="24" cy="12" rx="15" ry="5.5"/><path d="M9 12l3.4 25c.4 2.8 5.700 5 11.600 5s11.200-2.200 11.600-5L39 12"/><path class="hi" d="M11 23c3 2.600 8.200 3.800 13 3.800S34 25.600 37 23"/></g></symbol>
<symbol id="i-sqs" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="9" width="34" height="8" rx="4"/><rect x="7" y="20" width="34" height="8" rx="4"/><rect x="7" y="31" width="34" height="8" rx="4"/><path class="hi" d="M13 24h8m-3-3l3 3-3 3"/></g></symbol>
<symbol id="i-sns" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M13 13a16 16 0 0 0 0 22M35 13a16 16 0 0 1 0 22M19 18a8 8 0 0 0 0 12M29 18a8 8 0 0 1 0 12"/><circle class="hi hf" cx="24" cy="24" r="3.6"/></g></symbol>
<symbol id="i-ddb" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="24" cy="11" rx="14" ry="5.500"/><path d="M10 11v26c0 3 6.300 5.500 14 5.500S38 40 38 37V11"/><path class="hi" d="M10 24c0 3 6.300 5.500 14 5.500S38 27 38 24"/></g></symbol>
<symbol id="i-sfn" viewBox="0 0 48 48"><g fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 24h6c4 0 5-9 9-9h2M13 24h6c4 0 5 9 9 9h2"/><rect x="30" y="9" width="11" height="11" rx="3"/><rect x="30" y="28" width="11" height="11" rx="3"/><circle class="hi" cx="9" cy="24" r="4"/></g></symbol>
</svg>
<div class="frame">
<aside class="rail">
<div>
<a class="brand" href="/">TheCloud</a>
<div class="signin">
<h2>Welcome back</h2>
<p class="lead">Continue with your Authentik account to open the console.</p>
<div class="dest mono" id="dest">Landing on <span id="destPath"></span></div>
<div class="alert" id="alert" role="alert"></div>
<a class="btn" id="go" href="/api/auth/oidc/login">
<svg class="sh" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 3v6c0 4.500-3.200 8-8 9-4.800-1-8-4.500-8-9V6l8-3z"/><path d="M9 12l2 2 4-4"/></svg>
<span class="sp" aria-hidden="true"></span><span id="label">Sign in with Authentik</span>
</a>
<p class="sso">Single sign-on. MFA happens there.</p>
</div>
</div>
<div class="foot">
<div class="tg" role="group" aria-label="Theme">
<button data-t="dark" aria-label="Dark theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M21 13.500A9 9 0 1 1 10.500 3a7 7 0 0 0 10.500 10.500z"/></svg></button>
<button data-t="light" aria-label="Light theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.900 4.900l1.400 1.400M17.700 17.700l1.400 1.400M4.900 19.100l1.400-1.400M17.700 6.300l1.400-1.400"/></svg></button>
<button data-t="cyber" aria-label="Cyber theme"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.400" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l6 6-6 6M12 19h8"/></svg></button>
</div>
<p class="legal">TheCloud is powered by Floci and is not affiliated with Amazon Web Services.</p>
</div>
</aside>
<main class="panel" id="panel">
<div class="dot d1"></div><div class="dot d2"></div>
<div class="pglow" id="pglow"></div>
<div class="inner">
<h1>Your own cloud, on your own hardware.</h1>
<ul class="tiles">
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-lambda"/></svg><span><b>Invoke functions</b><small>Lambda</small></span></li>
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-s3"/></svg><span><b>Store objects</b><small>S3</small></span></li>
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-sqs"/></svg><span><b>Queue messages</b><small>SQS</small></span></li>
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-sns"/></svg><span><b>Publish events</b><small>SNS</small></span></li>
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-ddb"/></svg><span><b>Query tables</b><small>DynamoDB</small></span></li>
<li class="tile"><svg class="ic" aria-hidden="true"><use href="#i-sfn"/></svg><span><b>Orchestrate workflows</b><small>Step Functions</small></span></li>
</ul>
<p class="tag">Build and test against AWS services locally, with SSO in front.</p>
</div>
</main>
</div>
<script>
(function(){
var root=document.documentElement,btns=document.querySelectorAll('.tg button');
function setTheme(t,anim){
if(anim){document.body.classList.add('fade');setTimeout(function(){document.body.classList.remove('fade')},600)}
root.setAttribute('data-theme',t);
try{localStorage.setItem('thecloud-theme',t)}catch(e){}
btns.forEach(function(b){b.classList.toggle('on',b.dataset.t===t)});
}
var t0=matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';
try{var sv=localStorage.getItem('thecloud-theme');if(sv==='dark'||sv==='light'||sv==='cyber')t0=sv}catch(e){}
setTheme(t0,false);
btns.forEach(function(b){b.addEventListener('click',function(){setTheme(b.dataset.t,true)})});
document.addEventListener('keydown',function(e){
if((e.key==='t'||e.key==='T')&&!e.metaKey&&!e.ctrlKey&&!e.altKey){
var o=['dark','light','cyber'];setTheme(o[(o.indexOf(root.getAttribute('data-theme'))+1)%3],true)}
});
var panel=document.getElementById('panel'),gl=document.getElementById('pglow');
panel.addEventListener('mousemove',function(e){var r=panel.getBoundingClientRect();gl.style.left=(e.clientX-r.left)+'px';gl.style.top=(e.clientY-r.top)+'px';gl.style.opacity='1'});
panel.addEventListener('mouseleave',function(){gl.style.opacity='0'});
var p=new URLSearchParams(location.search),go=document.getElementById('go'),label=document.getElementById('label');
var rt=p.get('returnTo')||'/';
if(!(rt.length<512&&rt.charAt(0)==='/'&&rt.charAt(1)!=='/'&&rt.charAt(1)!=='\\'&&!/[\u0000-\u001f]/.test(rt)))rt='/';
go.href='/api/auth/oidc/login?returnTo='+encodeURIComponent(rt);
if(rt!=='/'){document.getElementById('destPath').textContent=rt;document.getElementById('dest').className='dest mono on'}
var msgs={access_denied:'Your account is not allowed to use this console. Ask the admin to grant access in Authentik.',session_expired:'Your session ended. Sign in again to continue.',state_mismatch:'The sign-in request expired. Start again.',invalid_state:'The sign-in request expired. Start again.',oidc_failed:'Authentik could not complete the sign-in. Try again in a moment.'};
var er=p.get('error');
if(er){var a=document.getElementById('alert');a.textContent=Object.prototype.hasOwnProperty.call(msgs,er)?msgs[er]:'Sign-in did not complete. Try again.';a.className='alert on'}
go.addEventListener('click',function(ev){
if(go.getAttribute('aria-busy')==='true'){ev.preventDefault();return}
go.setAttribute('aria-busy','true');label.textContent='Redirecting to Authentik';
});
addEventListener('pageshow',function(){go.removeAttribute('aria-busy');label.textContent='Sign in with Authentik'});
})();
</script>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
(() => {
const root = document.documentElement;
const buttons = document.querySelectorAll('.tg button');
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
function setTheme(theme) {
root.setAttribute('data-theme', theme);
try {
localStorage.setItem('thecloud-theme', theme);
} catch (ignored) {
// Theme persistence is optional when browser storage is unavailable.
}
buttons.forEach((button) => button.classList.toggle('on', button.dataset.t === theme));
}
let initialTheme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
try {
const savedTheme = localStorage.getItem('thecloud-theme');
if (savedTheme === 'dark' || savedTheme === 'light' || savedTheme === 'cyber') {
initialTheme = savedTheme;
}
} catch (ignored) {
// The OS preference remains the fallback when browser storage is unavailable.
}
setTheme(initialTheme);
buttons.forEach((button) => button.addEventListener('click', () => setTheme(button.dataset.t)));
document.addEventListener('keydown', (event) => {
if ((event.key === 't' || event.key === 'T') && !event.metaKey && !event.ctrlKey && !event.altKey && !/INPUT|TEXTAREA/.test(event.target.tagName)) {
const themes = ['dark', 'light', 'cyber'];
setTheme(themes[(themes.indexOf(root.getAttribute('data-theme')) + 1) % themes.length]);
}
});
const lines = ['hello, engineer.', 'aws s3 mb s3://my-bucket', 'your own AWS, on your own hardware.', 'sfn start-execution --local', 'welcome to TheCloud.'];
const typewriter = document.getElementById('tw');
let line = 0;
let count = 0;
let deleting = false;
if (reducedMotion) {
typewriter.textContent = lines[2];
} else {
function type() {
const word = lines[line];
if (deleting) {
count--;
typewriter.textContent = word.slice(0, count);
if (count === 0) {
deleting = false;
line = (line + 1) % lines.length;
setTimeout(type, 400);
return;
}
setTimeout(type, 35);
return;
}
count++;
typewriter.textContent = word.slice(0, count);
if (count === word.length) {
deleting = true;
setTimeout(type, 2200);
return;
}
setTimeout(type, 75 + Math.random() * 50);
}
type();
}
const hero = document.getElementById('top');
const glow = document.getElementById('grid-glow');
if (!reducedMotion) {
hero.addEventListener('mousemove', (event) => {
const rect = hero.getBoundingClientRect();
glow.style.left = `${event.clientX - rect.left}px`;
glow.style.top = `${event.clientY - rect.top}px`;
glow.style.opacity = '1';
});
hero.addEventListener('mouseleave', () => {
glow.style.opacity = '0';
});
}
const progress = document.getElementById('bar');
addEventListener('scroll', () => {
const height = document.documentElement.scrollHeight - innerHeight;
progress.style.width = `${height > 0 ? scrollY / height * 100 : 0}%`;
}, {passive: true});
const form = document.querySelector('[data-sso-form]');
if (form) {
form.addEventListener('submit', () => {
const button = form.querySelector('.btn');
button.classList.add('is-loading');
button.disabled = true;
document.getElementById('label').textContent = 'Redirecting...';
});
}
const copy = document.getElementById('cp');
if (copy) {
copy.addEventListener('click', () => {
navigator.clipboard.writeText(document.getElementById('code').innerText).then(() => {
copy.textContent = 'copied';
copy.classList.add('ok');
setTimeout(() => {
copy.textContent = 'copy';
copy.classList.remove('ok');
}, 1800);
});
});
}
})();
+2 -2
View File
@@ -288,7 +288,7 @@ function loginErrorMessage(code: string | undefined): string | null {
return 'We could not complete your sign-in. Please try again.' return 'We could not complete your sign-in. Please try again.'
} }
function isPublic(path: string): boolean { return path === '/login' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' } function isPublic(path: string): boolean { return path === '/login' || path === '/login.js' || path === '/TheCloud.png' || path === '/authentik.svg' || path.startsWith('/aws-icons/') || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' }
function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) } function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) }
function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' } function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' }
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[character] ?? character)) } function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[character] ?? character)) }
@@ -336,4 +336,4 @@ function parseTrustedProxies(value: string | undefined): BlockList | null {
} }
return proxies return proxies
} }
function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-mgwziK4YXYUt2nNB711bt/nNzs3je0rpErGfX/6eqmc='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') } function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-AbRSUfgtaJwJXaS+/FXX4ATIx390dHnVN1Z7G7jqB8s='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') }
+2 -1
View File
@@ -19,6 +19,7 @@ export function renderLoginPage(options: LoginPageOptions): string {
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light"> <meta name="color-scheme" content="dark light">
<link rel="icon" href="/TheCloud.png" />
<title>Sign in · TheCloud</title> <title>Sign in · TheCloud</title>
<style nonce="${options.styleNonce}"> <style nonce="${options.styleNonce}">
:root[data-theme=dark]{--bg:#07111d;--rail:#091727;--panel:#0c1a2b;--fg:#e8f1fa;--dim:#bdd0e3;--mut:#8aa3bc;--ac:#6cc4ff;--acr:108,196,255;--ac2:#ffb547;--bd:rgba(232,241,250,.1);--bd2:rgba(232,241,250,.2);--on:#06223a;--err:#ff9a9a;--errbg:#3a1822} :root[data-theme=dark]{--bg:#07111d;--rail:#091727;--panel:#0c1a2b;--fg:#e8f1fa;--dim:#bdd0e3;--mut:#8aa3bc;--ac:#6cc4ff;--acr:108,196,255;--ac2:#ffb547;--bd:rgba(232,241,250,.1);--bd2:rgba(232,241,250,.2);--on:#06223a;--err:#ff9a9a;--errbg:#3a1822}
@@ -42,7 +43,7 @@ export function renderLoginPage(options: LoginPageOptions): string {
<span class="f">aws</span> sqs create-queue <span class="o">--queue-name</span> fulfilment-queue <span class="f">aws</span> sqs create-queue <span class="o">--queue-name</span> fulfilment-queue
<span class="f">aws</span> stepfunctions list-state-machines</code></pre></div></section> <span class="f">aws</span> stepfunctions list-state-machines</code></pre></div></section>
<footer class="mono"><div><span><span class="prompt">$</span> TheCloud</span><span>press <kbd>T</kbd> to switch theme</span></div></footer></div></div> <footer class="mono"><div><span><span class="prompt">$</span> TheCloud</span><span>press <kbd>T</kbd> to switch theme</span></div></footer></div></div>
<script nonce="${options.styleNonce}">(function(){var root=document.documentElement,btns=document.querySelectorAll('.tg button'),still=matchMedia('(prefers-reduced-motion: reduce)').matches;function setTheme(t){root.setAttribute('data-theme',t);try{localStorage.setItem('thecloud-theme',t)}catch(e){}btns.forEach(function(b){b.classList.toggle('on',b.dataset.t===t)})}var initial=matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';try{var saved=localStorage.getItem('thecloud-theme');if(saved==='dark'||saved==='light'||saved==='cyber')initial=saved}catch(e){}setTheme(initial);btns.forEach(function(b){b.addEventListener('click',function(){setTheme(b.dataset.t)})});document.addEventListener('keydown',function(e){if((e.key==='t'||e.key==='T')&&!e.metaKey&&!e.ctrlKey&&!e.altKey&&!/INPUT|TEXTAREA/.test(e.target.tagName)){var themes=['dark','light','cyber'];setTheme(themes[(themes.indexOf(root.getAttribute('data-theme'))+1)%themes.length])}});var lines=['hello, engineer.','aws s3 mb s3://my-bucket','your own AWS, on your own hardware.','sfn start-execution --local','welcome to TheCloud.'],el=document.getElementById('tw'),line=0,count=0,deleting=false;if(still){el.textContent=lines[2]}else{(function type(){var word=lines[line];if(deleting){count--;el.textContent=word.slice(0,count);if(count===0){deleting=false;line=(line+1)%lines.length;setTimeout(type,400);return}setTimeout(type,35);return}count++;el.textContent=word.slice(0,count);if(count===word.length){deleting=true;setTimeout(type,2200);return}setTimeout(type,75+Math.random()*50)})()}var hero=document.getElementById('top'),glow=document.getElementById('grid-glow');if(!still){hero.addEventListener('mousemove',function(e){var rect=hero.getBoundingClientRect();glow.style.left=(e.clientX-rect.left)+'px';glow.style.top=(e.clientY-rect.top)+'px';glow.style.opacity='1'});hero.addEventListener('mouseleave',function(){glow.style.opacity='0'})}var bar=document.getElementById('bar');addEventListener('scroll',function(){var height=document.documentElement.scrollHeight-innerHeight;bar.style.width=(height>0?scrollY/height*100:0)+'%'},{passive:true});var form=document.querySelector('[data-sso-form]');if(form)form.addEventListener('submit',function(){var button=form.querySelector('.btn');button.classList.add('is-loading');button.disabled=true;document.getElementById('label').textContent='Redirecting...' });var copy=document.getElementById('cp');if(copy)copy.addEventListener('click',function(){navigator.clipboard.writeText(document.getElementById('code').innerText).then(function(){copy.textContent='copied';copy.classList.add('ok');setTimeout(function(){copy.textContent='copy';copy.classList.remove('ok')},1800)})})})()</script> <script defer src="/login.js"></script>
</body> </body>
</html>` </html>`
} }
+1 -1
View File
@@ -10,7 +10,7 @@
var t = localStorage.getItem("floci-theme"); var t = localStorage.getItem("floci-theme");
document.documentElement.setAttribute( document.documentElement.setAttribute(
"data-theme", "data-theme",
t === "light" ? "light" : "dark", t === "light" || t === "cyber" ? t : "dark",
); );
})(); })();
</script> </script>
+4
View File
@@ -15,8 +15,12 @@ export default function App() {
<Route index element={<Navigate to="/console/aws" replace/>}/> <Route index element={<Navigate to="/console/aws" replace/>}/>
<Route path="/dashboard" element={<Navigate to="/console/aws" replace/>}/> <Route path="/dashboard" element={<Navigate to="/console/aws" replace/>}/>
<Route path="/console" element={<Navigate to="/console/aws" replace/>}/> <Route path="/console" element={<Navigate to="/console/aws" replace/>}/>
<Route path="/console/azure" element={<Navigate to="/console/aws" replace/>}/>
<Route path="/console/gcp" element={<Navigate to="/console/aws" replace/>}/>
<Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/> <Route path="/console/:cloud" element={<CloudConsoleHomePage/>}/>
<Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/> <Route path="/cloud-explorer" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
<Route path="/cloud-explorer/azure/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
<Route path="/cloud-explorer/gcp/*" element={<Navigate to="/cloud-explorer/aws/storage" replace/>}/>
<Route path="/cloud-explorer/:cloud/:service" element={<CloudExplorerPage/>}/> <Route path="/cloud-explorer/:cloud/:service" element={<CloudExplorerPage/>}/>
<Route path="/cloud-explorer/:cloud/:service/:resourceId/data" element={<DatabaseDataPage/>}/> <Route path="/cloud-explorer/:cloud/:service/:resourceId/data" element={<DatabaseDataPage/>}/>
<Route path="/cloud-explorer/:cloud/nosql/:resourceId/data/items/new" element={<DynamoDbItemEditorPage/>}/> <Route path="/cloud-explorer/:cloud/nosql/:resourceId/data/items/new" element={<DynamoDbItemEditorPage/>}/>
+7 -12
View File
@@ -1,5 +1,5 @@
import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom' import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom'
import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun} from 'lucide-react' import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun, Terminal} from 'lucide-react'
import {useCallback, useEffect, useRef, useState} from 'react' import {useCallback, useEffect, useRef, useState} from 'react'
import theCloud from '../../../../TheCloud.png' import theCloud from '../../../../TheCloud.png'
import {useTheme} from '@/lib/useTheme' import {useTheme} from '@/lib/useTheme'
@@ -157,9 +157,8 @@ function groupByGroup(services: CloudServiceDescriptor[]): Array<[string, CloudS
export function Layout() { export function Layout() {
const location = useLocation() const location = useLocation()
const activeCloud = activeCloudFromPath(location.pathname) const activeCloud = activeCloudFromPath(location.pathname)
const {theme, toggle} = useTheme() const {theme, setTheme} = useTheme()
const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar() const {collapsed, toggle: toggleSidebar, toggleRef} = useSidebar()
const isDark = theme === 'dark'
const {data, isError} = useQuery({ const {data, isError} = useQuery({
queryKey: ['cloud-status', activeCloud], queryKey: ['cloud-status', activeCloud],
queryFn: ({signal}) => getCloudStatus(activeCloud, signal), queryFn: ({signal}) => getCloudStatus(activeCloud, signal),
@@ -209,15 +208,11 @@ export function Layout() {
<div className="shell"> <div className="shell">
<header className="topbar"> <header className="topbar">
<TopbarSearch/> <TopbarSearch/>
<button <div className="theme-switcher" role="group" aria-label="Console theme">
className="icon-btn" <button className={`icon-btn ${theme === 'dark' ? 'active' : ''}`} type="button" onClick={() => setTheme('dark')} title="Dark theme" aria-label="Dark theme" aria-pressed={theme === 'dark'}><Moon size={14} aria-hidden="true"/></button>
type="button" <button className={`icon-btn ${theme === 'light' ? 'active' : ''}`} type="button" onClick={() => setTheme('light')} title="Light theme" aria-label="Light theme" aria-pressed={theme === 'light'}><Sun size={14} aria-hidden="true"/></button>
onClick={toggle} <button className={`icon-btn ${theme === 'cyber' ? 'active' : ''}`} type="button" onClick={() => setTheme('cyber')} title="Terminal theme" aria-label="Terminal theme" aria-pressed={theme === 'cyber'}><Terminal size={14} aria-hidden="true"/></button>
title={isDark ? 'Switch to light theme' : 'Switch to dark theme'} </div>
aria-label={isDark ? 'Switch to light theme' : 'Switch to dark theme'}
>
{isDark ? <Sun size={14} aria-hidden="true"/> : <Moon size={14} aria-hidden="true"/>}
</button>
<div id="topbar-status" className="topbar-status"/> <div id="topbar-status" className="topbar-status"/>
<AccountSwitcher/> <AccountSwitcher/>
<button className="icon-btn" type="button" title="Sign out" aria-label="Sign out" onClick={() => void signOut()}> <button className="icon-btn" type="button" title="Sign out" aria-label="Sign out" onClick={() => void signOut()}>
@@ -99,6 +99,19 @@ test('shows an object extension instead of a generic object type', async () => {
expect(await screen.findByText('JSON file')).toBeInTheDocument() expect(await screen.findByText('JSON file')).toBeInTheDocument()
}) })
test('offers a copy action for the AWS bucket name', async () => {
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: []})
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
render(
<QueryClientProvider client={queryClient}>
<StorageObjectBrowser cloud="aws" resource={bucket} runtimeReachable capabilities={[]} onSelectObject={() => {}}/>
</QueryClientProvider>,
)
expect(await screen.findByRole('button', {name: 'Copy Bucket name'})).toBeInTheDocument()
})
test('opens the object action with copy selected', async () => { test('opens the object action with copy selected', async () => {
apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: [{ apiMocks.listStorageObjects.mockResolvedValue({prefix: '', objects: [{
key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {}, key: 'orders/order.json', name: 'order.json', type: 'object', size: 10, lastModified: null, metadata: {},
@@ -17,6 +17,7 @@ import type {CapabilitySchema, ObjectActionName} from '@/types/schema'
import {formatBytes, objectIconKind, objectPreviewKind} from '@/lib/format' import {formatBytes, objectIconKind, objectPreviewKind} from '@/lib/format'
import {ImagePreviewModal} from '@/components/ImagePreviewModal' import {ImagePreviewModal} from '@/components/ImagePreviewModal'
import {ObjectThumbnail} from '@/components/ObjectThumbnail' import {ObjectThumbnail} from '@/components/ObjectThumbnail'
import {CopyValueButton} from '@/components/CopyValueButton'
interface StorageObjectBrowserProps { interface StorageObjectBrowserProps {
cloud: CloudProvider cloud: CloudProvider
@@ -268,7 +269,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
<div className="object-browser-header"> <div className="object-browser-header">
<div> <div>
<p className="eyebrow">Objects</p> <p className="eyebrow">Objects</p>
<h3>{resource.name}</h3> <h3>{cloud === 'aws' ? <span className="copy-value">{resource.name}<CopyValueButton value={resource.name} label="Bucket name"/></span> : resource.name}</h3>
<div className="object-browser-subtitle"> <div className="object-browser-subtitle">
<ObjectBreadcrumb prefix={prefix} onNavigate={navigate}/> <ObjectBreadcrumb prefix={prefix} onNavigate={navigate}/>
<span>{totalFolders} folders, {totalFiles} {objectLabel}</span> <span>{totalFolders} folders, {totalFiles} {objectLabel}</span>
+72
View File
@@ -218,6 +218,58 @@
--sidebar-overlay-shadow: 6px 0 28px rgba(38, 48, 82, 0.18); --sidebar-overlay-shadow: 6px 0 28px rgba(38, 48, 82, 0.18);
} }
[data-theme="cyber"] {
color-scheme: dark;
--bg: #02060a;
--surface: #050d10;
--elevated: #071216;
--raised: #0a191d;
--raised-hover: #10252a;
--border: rgba(78, 255, 150, 0.28);
--border-faint: rgba(78, 255, 150, 0.16);
--border-lighter: rgba(78, 255, 150, 0.08);
--border-hover: #ffea00;
--active-nav-bg: rgba(78, 255, 150, 0.14);
--text: #8affb8;
--text-bright: #d6ffe6;
--text-2: #62d992;
--text-3: #2f9a68;
--text-muted: var(--text-3);
--text-nav: #8affb8;
--status-ok: #4eff96;
--status-warn: #ffea00;
--status-error: #ff8da1;
--accent: #4eff96;
--accent-tint: rgba(78, 255, 150, 0.14);
--accent-tint-faint: rgba(78, 255, 150, 0.07);
--accent-tint-border: rgba(78, 255, 150, 0.34);
--link: #ffea00;
--link-tint: rgba(255, 234, 0, 0.08);
--link-tint-hover: rgba(255, 234, 0, 0.14);
--link-tint-border: rgba(255, 234, 0, 0.3);
--hover: rgba(78, 255, 150, 0.06);
--hover-md: rgba(78, 255, 150, 0.1);
--hover-lg: rgba(78, 255, 150, 0.14);
--input-border: rgba(78, 255, 150, 0.42);
--input-bg: #030b0d;
--connection-bg: #02090c;
--kbd-border: rgba(78, 255, 150, 0.35);
--scrollbar-thumb: #26784e;
--service-card-bg: #050d10;
--service-card-hover: #071216;
--fn-card-hover: #0a191d;
--bulk-bar-bg: #071b13;
--bulk-bar-border: #4eff96;
--bulk-bar-text: #d6ffe6;
--code-bg: #000;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -626,6 +678,26 @@ pre,
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.theme-switcher {
display: inline-flex;
gap: 2px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--connection-bg);
}
.theme-switcher .icon-btn {
width: 26px;
height: 26px;
}
.theme-switcher .icon-btn.active {
border-color: var(--accent);
background: var(--accent);
color: var(--bg);
}
.search { .search {
position: relative; position: relative;
width: min(480px, 100%); width: min(480px, 100%);
+3 -5
View File
@@ -1,10 +1,10 @@
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
type Theme = 'dark' | 'light' export type Theme = 'dark' | 'light' | 'cyber'
function getStored(): Theme { function getStored(): Theme {
const v = localStorage.getItem('floci-theme') const v = localStorage.getItem('floci-theme')
return v === 'light' ? 'light' : 'dark' return v === 'light' || v === 'cyber' ? v : 'dark'
} }
export function useTheme() { export function useTheme() {
@@ -15,7 +15,5 @@ export function useTheme() {
localStorage.setItem('floci-theme', theme) localStorage.setItem('floci-theme', theme)
}, [theme]) }, [theme])
const toggle = () => setTheme(t => t === 'dark' ? 'light' : 'dark') return {theme, setTheme}
return {theme, toggle}
} }
@@ -1,6 +1,5 @@
import {Cloud} from 'lucide-react' import {Cloud} from 'lucide-react'
import {Navigate, useNavigate, useParams, useSearchParams} from 'react-router-dom' import {Navigate, useNavigate, useParams, useSearchParams} from 'react-router-dom'
import {CloudSelector} from '@/components/CloudSelector'
import { import {
ProviderBanner, ProviderBanner,
RuntimeFlow, RuntimeFlow,
@@ -39,16 +38,6 @@ export function CloudConsoleHomePage() {
<p className="muted">Cloud-aware local runtime overview</p> <p className="muted">Cloud-aware local runtime overview</p>
</div> </div>
</div> </div>
<div className="cloud-header-selectors">
<label>
<span>Cloud</span>
<CloudSelector
clouds={data.cloudsQuery.data ?? []}
selected={cloud}
onSelect={(nextCloud) => navigate(`/console/${nextCloud}`)}
/>
</label>
</div>
</div> </div>
<div className="content cloud-console-home"> <div className="content cloud-console-home">