GUI: redesign dashboard, compact connection, drop logs tab

Connection: incoming and outgoing servers sit side by side so the panel fits
the fixed window without scrolling.

Dashboard: replace the oversized "Bridge is running" hero with a compact
status bar (a state LED plus a one-line status and the stop button). The LED
is green only when realtime is actually connected and orange while it
reconnects, and the subtitle stays empty when everything is healthy so it
never repeats what the stat cards already show. Realtime no longer has its
own card. The Logs tab is gone: the activity log now lives at the bottom of
the dashboard, filling the leftover space and scrolling inside itself.
This commit is contained in:
Anthony
2026-06-12 18:06:18 +02:00
parent 5beb420034
commit 2764b052d8
5 changed files with 204 additions and 218 deletions
+81 -61
View File
@@ -126,104 +126,113 @@
.dashboard { .dashboard {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px;
}
.status-hero {
display: flex;
align-items: center;
gap: 14px; gap: 14px;
padding: 18px 20px; height: 100%;
border-radius: 14px; min-height: 0;
border: 1px solid var(--outline-variant);
background: var(--surface-container);
transition: all 0.3s;
} }
.status-hero.running { .dashboard > .status-bar,
border-color: color-mix(in srgb, var(--green) 40%, transparent); .dashboard > .start-section,
background: color-mix(in srgb, var(--success-container) 30%, var(--surface-container)); .dashboard > .stats-grid,
} .dashboard > .error-text {
.status-hero.starting {
border-color: color-mix(in srgb, var(--orange) 40%, transparent);
}
.hero-indicator {
position: relative;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0; flex-shrink: 0;
} }
.pulse-ring { /* Compact status bar, accented by state. Replaces the old oversized hero. */
position: absolute; .status-bar {
width: 36px; display: flex;
height: 36px; align-items: center;
border-radius: 50%; gap: 12px;
border: 2px solid var(--green); padding: 12px 16px;
opacity: 0; border-radius: 12px;
animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite; border: 1px solid var(--outline-variant);
background: var(--surface-container);
} }
.pulse-ring.orange { .status-bar.running {
border-color: var(--orange); border-color: color-mix(in srgb, var(--green) 35%, transparent);
background: color-mix(in srgb, var(--success-container) 25%, var(--surface-container));
} }
.pulse-dot { .status-bar.starting {
width: 12px; border-color: color-mix(in srgb, var(--orange) 35%, transparent);
height: 12px; }
.status-led {
width: 10px;
height: 10px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0;
background: var(--gray);
}
.status-led.running {
background: var(--green); background: var(--green);
animation: breathe 2s ease-in-out infinite; box-shadow: 0 0 0 4px color-mix(in srgb, var(--green) 16%, transparent);
animation: breathe 2.4s ease-in-out infinite;
} }
.pulse-dot.orange { .status-led.starting {
background: var(--orange); background: var(--orange);
} animation: breathe 1.2s ease-in-out infinite;
.static-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
@keyframes ping {
0% { transform: scale(0.8); opacity: 0.6; }
75%, 100% { transform: scale(1.4); opacity: 0; }
} }
@keyframes breathe { @keyframes breathe {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.5; } 50% { opacity: 0.45; }
} }
.hero-text { .status-bar-text {
flex: 1; flex: 1;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 1px;
} }
.hero-text strong { .status-bar-text strong {
font-size: 15px; font-size: 14px;
color: var(--on-surface); color: var(--on-surface);
} }
.hero-uptime { .status-bar-sub {
font-size: 12px; font-size: 12px;
color: var(--on-surface-variant); color: var(--on-surface-variant);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.hero-action { .status-action {
padding: 6px 16px; padding: 6px 16px;
font-size: 12px; font-size: 12px;
flex-shrink: 0; flex-shrink: 0;
} }
/* Activity log lives at the bottom of the dashboard and fills the leftover
space, scrolling inside itself. */
.dash-logs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.dash-logs-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.dash-logs-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--on-surface-variant);
}
.start-section { .start-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -236,7 +245,7 @@
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 12px; gap: 12px;
} }
@@ -302,6 +311,17 @@
gap: 16px; gap: 16px;
} }
/* Incoming + outgoing side by side to keep the panel short (no scroll). */
.settings-row {
display: flex;
gap: 12px;
}
.settings-row > .settings-card {
flex: 1;
min-width: 0;
}
.panel-subtitle { .panel-subtitle {
font-size: 13px; font-size: 13px;
color: var(--on-surface-variant); color: var(--on-surface-variant);
+3 -11
View File
@@ -3,13 +3,12 @@ import { useBridge } from "./hooks/useBridge";
import { Dashboard } from "./components/Dashboard"; import { Dashboard } from "./components/Dashboard";
import { ConnectionPanel } from "./components/ConnectionPanel"; import { ConnectionPanel } from "./components/ConnectionPanel";
import { ConfigPanel } from "./components/ConfigPanel"; import { ConfigPanel } from "./components/ConfigPanel";
import { LogsPanel } from "./components/LogsPanel";
import { BackupPanel } from "./components/BackupPanel"; import { BackupPanel } from "./components/BackupPanel";
import { statusLabel, isError } from "./types"; import { statusLabel, isError } from "./types";
import logo from "./assets/tuta-logo.svg"; import logo from "./assets/tuta-logo.svg";
import "./App.css"; import "./App.css";
type Tab = "dashboard" | "connection" | "config" | "backup" | "logs"; type Tab = "dashboard" | "connection" | "config" | "backup";
function App() { function App() {
const [tab, setTab] = useState<Tab>("dashboard"); const [tab, setTab] = useState<Tab>("dashboard");
@@ -64,12 +63,6 @@ function App() {
> >
Backup Backup
</button> </button>
<button
className={tab === "logs" ? "active" : ""}
onClick={() => setTab("logs")}
>
Logs{bridge.logs.length > 0 ? ` (${bridge.logs.length})` : ""}
</button>
</nav> </nav>
</header> </header>
<main className="app-content"> <main className="app-content">
@@ -79,8 +72,10 @@ function App() {
stats={bridge.stats} stats={bridge.stats}
hasSavedSession={bridge.hasSavedSession} hasSavedSession={bridge.hasSavedSession}
loading={bridge.loading} loading={bridge.loading}
logs={bridge.logs}
onStart={bridge.startBridge} onStart={bridge.startBridge}
onStop={bridge.stopBridge} onStop={bridge.stopBridge}
onClearLogs={bridge.clearLogs}
/> />
)} )}
{tab === "connection" && ( {tab === "connection" && (
@@ -110,9 +105,6 @@ function App() {
onBackup={bridge.startBackup} onBackup={bridge.startBackup}
/> />
)} )}
{tab === "logs" && (
<LogsPanel logs={bridge.logs} onClear={bridge.clearLogs} />
)}
</main> </main>
</div> </div>
); );
+9 -7
View File
@@ -28,9 +28,10 @@ export function ConnectionPanel({ config, status, bridgePassword, onRegeneratePa
<div className="connection-panel"> <div className="connection-panel">
<p className="panel-subtitle">Configure your mail client with these settings</p> <p className="panel-subtitle">Configure your mail client with these settings</p>
<div className="settings-row">
<div className="settings-card"> <div className="settings-card">
<div className="settings-section"> <div className="settings-section">
<h3>Incoming Mail (IMAP)</h3> <h3>Incoming (IMAP)</h3>
<div className="settings-grid"> <div className="settings-grid">
<span className="setting-key">Server</span> <span className="setting-key">Server</span>
<code className="setting-val">127.0.0.1</code> <code className="setting-val">127.0.0.1</code>
@@ -40,11 +41,11 @@ export function ConnectionPanel({ config, status, bridgePassword, onRegeneratePa
<code className="setting-val">SSL/TLS</code> <code className="setting-val">SSL/TLS</code>
</div> </div>
</div> </div>
</div>
<div className="settings-divider" /> <div className="settings-card">
<div className="settings-section"> <div className="settings-section">
<h3>Outgoing Mail (SMTP)</h3> <h3>Outgoing (SMTP)</h3>
<div className="settings-grid"> <div className="settings-grid">
<span className="setting-key">Server</span> <span className="setting-key">Server</span>
<code className="setting-val">127.0.0.1</code> <code className="setting-val">127.0.0.1</code>
@@ -54,15 +55,16 @@ export function ConnectionPanel({ config, status, bridgePassword, onRegeneratePa
<code className="setting-val">SSL/TLS</code> <code className="setting-val">SSL/TLS</code>
</div> </div>
</div> </div>
</div>
</div>
<div className="settings-divider" /> <div className="settings-card">
<div className="settings-section"> <div className="settings-section">
<h3>Authentication</h3> <h3>Authentication</h3>
<div className="settings-grid"> <div className="settings-grid">
<span className="setting-key">Username</span> <span className="setting-key">Username</span>
<code className="setting-val copyable" onClick={() => copyToClipboard(config.email)}> <code className="setting-val copyable" onClick={() => copyToClipboard(config.email)}>
{config.email || ""} {config.email || "(not set)"}
</code> </code>
<span className="setting-key">Password</span> <span className="setting-key">Password</span>
<div className="password-field-inline"> <div className="password-field-inline">
+87 -78
View File
@@ -1,28 +1,16 @@
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import type { BridgeStatus, BridgeStats, WsStatus } from "../types"; import type { BridgeStatus, BridgeStats } from "../types";
import { isError } from "../types"; import { isError } from "../types";
function wsBadge(status: WsStatus): { color: string; label: string; pulsing: boolean } {
switch (status) {
case "Connected":
return { color: "var(--green)", label: "Connected", pulsing: false };
case "Connecting":
return { color: "var(--orange)", label: "Connecting…", pulsing: true };
case "Reconnecting":
return { color: "var(--orange)", label: "Reconnecting…", pulsing: true };
case "Stopped":
default:
return { color: "var(--gray)", label: "Off", pulsing: false };
}
}
interface Props { interface Props {
status: BridgeStatus | null; status: BridgeStatus | null;
stats: BridgeStats; stats: BridgeStats;
hasSavedSession: boolean; hasSavedSession: boolean;
loading: boolean; loading: boolean;
logs: string[];
onStart: (password?: string) => Promise<void>; onStart: (password?: string) => Promise<void>;
onStop: () => Promise<void>; onStop: () => Promise<void>;
onClearLogs: () => void;
} }
function formatUptime(secs: number): string { function formatUptime(secs: number): string {
@@ -33,17 +21,62 @@ function formatUptime(secs: number): string {
return `${h}h ${m}m`; return `${h}h ${m}m`;
} }
export function Dashboard({ status, stats, hasSavedSession, loading, onStart, onStop }: Props) { export function Dashboard({
status,
stats,
hasSavedSession,
loading,
logs,
onStart,
onStop,
onClearLogs,
}: Props) {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const logEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]);
if (!status) { if (!status) {
return <div className="dashboard"><p className="muted">Connecting...</p></div>; return (
<div className="dashboard">
<p className="muted">Connecting...</p>
</div>
);
} }
const isRunning = status === "Running"; const isRunning = status === "Running";
const isStarting = status === "Starting"; const isStarting = status === "Starting";
const isStopped = status === "Stopped" || isError(status); const errored = isError(status);
const isStopped = status === "Stopped" || errored;
const needsPassword = isStopped && !hasSavedSession; const needsPassword = isStopped && !hasSavedSession;
const wsConnected = stats.ws_status === "Connected";
// LED colour reflects real health: green only when running and realtime is
// actually connected, orange while connecting or reconnecting, grey when off.
const led = isRunning ? (wsConnected ? "running" : "starting") : isStarting ? "starting" : "stopped";
const accent = isRunning ? "running" : isStarting ? "starting" : "stopped";
const title = isRunning
? "Bridge running"
: isStarting
? "Connecting…"
: errored
? "Connection failed"
: "Bridge stopped";
// Subtitle only carries information the rest of the screen doesn't already
// show: nothing when everything is healthy.
const subtitle = isRunning
? wsConnected
? ""
: "Realtime reconnecting…"
: isStarting
? "Signing in and syncing your mailbox"
: errored
? "See the activity log below"
: "Start the bridge to connect your mail client";
const handleStart = async () => { const handleStart = async () => {
if (needsPassword) { if (needsPassword) {
@@ -56,50 +89,20 @@ export function Dashboard({ status, stats, hasSavedSession, loading, onStart, on
return ( return (
<div className="dashboard"> <div className="dashboard">
{isRunning && ( <div className={`status-bar ${accent}`}>
<div className="status-hero running"> <span className={`status-led ${led}`} />
<div className="hero-indicator"> <div className="status-bar-text">
<span className="pulse-ring" /> <strong>{title}</strong>
<span className="pulse-dot" /> {subtitle && <span className="status-bar-sub">{subtitle}</span>}
</div> </div>
<div className="hero-text"> {(isRunning || isStarting) && (
<strong>Bridge is running</strong> <button className="status-action" onClick={onStop} disabled={loading}>
{stats.uptime_secs != null && ( {loading ? "Stopping…" : "Stop"}
<span className="hero-uptime">Up {formatUptime(stats.uptime_secs)}</span>
)}
</div>
<button className="hero-action" onClick={onStop} disabled={loading}>
{loading ? "Stopping..." : "Stop"}
</button> </button>
</div>
)} )}
</div>
{isStarting && ( {errored && <p className="error-text">{status.Error}</p>}
<div className="status-hero starting">
<div className="hero-indicator">
<span className="pulse-ring orange" />
<span className="pulse-dot orange" />
</div>
<div className="hero-text">
<strong>Connecting...</strong>
</div>
</div>
)}
{isStopped && (
<div className="status-hero stopped">
<div className="hero-indicator">
<span className="static-dot" style={{ background: isError(status) ? "var(--red)" : "var(--gray)" }} />
</div>
<div className="hero-text">
<strong>{isError(status) ? "Connection failed" : "Bridge is stopped"}</strong>
</div>
</div>
)}
{isError(status) && (
<p className="error-text">{status.Error}</p>
)}
{isStopped && ( {isStopped && (
<div className="start-section"> <div className="start-section">
@@ -120,38 +123,44 @@ export function Dashboard({ status, stats, hasSavedSession, loading, onStart, on
onClick={handleStart} onClick={handleStart}
disabled={loading || (needsPassword && !password)} disabled={loading || (needsPassword && !password)}
> >
{loading ? "Connecting..." : "Start Bridge"} {loading ? "Connecting" : "Start Bridge"}
</button> </button>
</div> </div>
)} )}
<div className="stats-grid"> <div className="stats-grid">
<div className="stat-card"> <div className="stat-card">
<span className="stat-value">{stats.mails_synced}</span> <span className="stat-value">{stats.mails_synced.toLocaleString()}</span>
<span className="stat-label">Emails synced</span> <span className="stat-label">Emails synced</span>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<span className="stat-value"> <span className="stat-value">
{stats.uptime_secs != null ? formatUptime(stats.uptime_secs) : "--"} {stats.uptime_secs != null ? formatUptime(stats.uptime_secs) : ""}
</span> </span>
<span className="stat-label">Uptime</span> <span className="stat-label">Uptime</span>
</div> </div>
<div className="stat-card"> </div>
<span className="ws-badge">
{(() => { <div className="dash-logs">
const b = wsBadge(stats.ws_status); <div className="dash-logs-header">
return ( <span className="dash-logs-title">Activity</span>
<> {logs.length > 0 && (
<span <button className="inline-btn" onClick={onClearLogs}>
className={"ws-dot" + (b.pulsing ? " pulsing" : "")} Clear
style={{ background: b.color }} </button>
/> )}
<span className="ws-text">{b.label}</span> </div>
</> <div className="logs-container">
); {logs.length === 0 ? (
})()} <span className="logs-empty">Waiting for activity</span>
</span> ) : (
<span className="stat-label">Realtime</span> logs.map((line, i) => (
<div key={i} className="log-line">
{line}
</div>
))
)}
<div ref={logEndRef} />
</div> </div>
</div> </div>
</div> </div>
-37
View File
@@ -1,37 +0,0 @@
import { useEffect, useRef } from "react";
interface Props {
logs: string[];
onClear: () => void;
}
export function LogsPanel({ logs, onClear }: Props) {
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]);
return (
<div className="panel logs-panel">
<div className="logs-header">
<h2>Logs</h2>
<button className="small" onClick={onClear}>
Clear
</button>
</div>
<div className="logs-container">
{logs.length === 0 ? (
<span className="logs-empty">No logs yet</span>
) : (
logs.map((line, i) => (
<div key={i} className="log-line">
{line}
</div>
))
)}
<div ref={endRef} />
</div>
</div>
);
}