feat(frontend): add a terminal theme and three-way theme switcher
Adds the cyber theme to the console and login page, replaces the dark/light toggle with a theme switcher, and moves the login page script to /login.js so it is served under the existing CSP.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) }
|
function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[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') }
|
||||||
|
|||||||
@@ -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>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()}>
|
||||||
|
|||||||
@@ -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%);
|
||||||
|
|||||||
@@ -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}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user