mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
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:
+81
-61
@@ -126,104 +126,113 @@
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--outline-variant);
|
||||
background: var(--surface-container);
|
||||
transition: all 0.3s;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.status-hero.running {
|
||||
border-color: color-mix(in srgb, var(--green) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--success-container) 30%, var(--surface-container));
|
||||
}
|
||||
|
||||
.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;
|
||||
.dashboard > .status-bar,
|
||||
.dashboard > .start-section,
|
||||
.dashboard > .stats-grid,
|
||||
.dashboard > .error-text {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--green);
|
||||
opacity: 0;
|
||||
animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||
/* Compact status bar, accented by state. Replaces the old oversized hero. */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--outline-variant);
|
||||
background: var(--surface-container);
|
||||
}
|
||||
|
||||
.pulse-ring.orange {
|
||||
border-color: var(--orange);
|
||||
.status-bar.running {
|
||||
border-color: color-mix(in srgb, var(--green) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--success-container) 25%, var(--surface-container));
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
.status-bar.starting {
|
||||
border-color: color-mix(in srgb, var(--orange) 35%, transparent);
|
||||
}
|
||||
|
||||
.status-led {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--gray);
|
||||
}
|
||||
|
||||
.status-led.running {
|
||||
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);
|
||||
}
|
||||
|
||||
.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; }
|
||||
animation: breathe 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
.status-bar-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.hero-text strong {
|
||||
font-size: 15px;
|
||||
.status-bar-text strong {
|
||||
font-size: 14px;
|
||||
color: var(--on-surface);
|
||||
}
|
||||
|
||||
.hero-uptime {
|
||||
.status-bar-sub {
|
||||
font-size: 12px;
|
||||
color: var(--on-surface-variant);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero-action {
|
||||
.status-action {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -236,7 +245,7 @@
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -302,6 +311,17 @@
|
||||
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 {
|
||||
font-size: 13px;
|
||||
color: var(--on-surface-variant);
|
||||
|
||||
+3
-11
@@ -3,13 +3,12 @@ import { useBridge } from "./hooks/useBridge";
|
||||
import { Dashboard } from "./components/Dashboard";
|
||||
import { ConnectionPanel } from "./components/ConnectionPanel";
|
||||
import { ConfigPanel } from "./components/ConfigPanel";
|
||||
import { LogsPanel } from "./components/LogsPanel";
|
||||
import { BackupPanel } from "./components/BackupPanel";
|
||||
import { statusLabel, isError } from "./types";
|
||||
import logo from "./assets/tuta-logo.svg";
|
||||
import "./App.css";
|
||||
|
||||
type Tab = "dashboard" | "connection" | "config" | "backup" | "logs";
|
||||
type Tab = "dashboard" | "connection" | "config" | "backup";
|
||||
|
||||
function App() {
|
||||
const [tab, setTab] = useState<Tab>("dashboard");
|
||||
@@ -64,12 +63,6 @@ function App() {
|
||||
>
|
||||
Backup
|
||||
</button>
|
||||
<button
|
||||
className={tab === "logs" ? "active" : ""}
|
||||
onClick={() => setTab("logs")}
|
||||
>
|
||||
Logs{bridge.logs.length > 0 ? ` (${bridge.logs.length})` : ""}
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="app-content">
|
||||
@@ -79,8 +72,10 @@ function App() {
|
||||
stats={bridge.stats}
|
||||
hasSavedSession={bridge.hasSavedSession}
|
||||
loading={bridge.loading}
|
||||
logs={bridge.logs}
|
||||
onStart={bridge.startBridge}
|
||||
onStop={bridge.stopBridge}
|
||||
onClearLogs={bridge.clearLogs}
|
||||
/>
|
||||
)}
|
||||
{tab === "connection" && (
|
||||
@@ -110,9 +105,6 @@ function App() {
|
||||
onBackup={bridge.startBackup}
|
||||
/>
|
||||
)}
|
||||
{tab === "logs" && (
|
||||
<LogsPanel logs={bridge.logs} onClear={bridge.clearLogs} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,41 +28,43 @@ export function ConnectionPanel({ config, status, bridgePassword, onRegeneratePa
|
||||
<div className="connection-panel">
|
||||
<p className="panel-subtitle">Configure your mail client with these settings</p>
|
||||
|
||||
<div className="settings-row">
|
||||
<div className="settings-card">
|
||||
<div className="settings-section">
|
||||
<h3>Incoming (IMAP)</h3>
|
||||
<div className="settings-grid">
|
||||
<span className="setting-key">Server</span>
|
||||
<code className="setting-val">127.0.0.1</code>
|
||||
<span className="setting-key">Port</span>
|
||||
<code className="setting-val">{config.imap_port}</code>
|
||||
<span className="setting-key">Security</span>
|
||||
<code className="setting-val">SSL/TLS</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-card">
|
||||
<div className="settings-section">
|
||||
<h3>Outgoing (SMTP)</h3>
|
||||
<div className="settings-grid">
|
||||
<span className="setting-key">Server</span>
|
||||
<code className="setting-val">127.0.0.1</code>
|
||||
<span className="setting-key">Port</span>
|
||||
<code className="setting-val">{config.smtp_port}</code>
|
||||
<span className="setting-key">Security</span>
|
||||
<code className="setting-val">SSL/TLS</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-card">
|
||||
<div className="settings-section">
|
||||
<h3>Incoming Mail (IMAP)</h3>
|
||||
<div className="settings-grid">
|
||||
<span className="setting-key">Server</span>
|
||||
<code className="setting-val">127.0.0.1</code>
|
||||
<span className="setting-key">Port</span>
|
||||
<code className="setting-val">{config.imap_port}</code>
|
||||
<span className="setting-key">Security</span>
|
||||
<code className="setting-val">SSL/TLS</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-divider" />
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>Outgoing Mail (SMTP)</h3>
|
||||
<div className="settings-grid">
|
||||
<span className="setting-key">Server</span>
|
||||
<code className="setting-val">127.0.0.1</code>
|
||||
<span className="setting-key">Port</span>
|
||||
<code className="setting-val">{config.smtp_port}</code>
|
||||
<span className="setting-key">Security</span>
|
||||
<code className="setting-val">SSL/TLS</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-divider" />
|
||||
|
||||
<div className="settings-section">
|
||||
<h3>Authentication</h3>
|
||||
<div className="settings-grid">
|
||||
<span className="setting-key">Username</span>
|
||||
<code className="setting-val copyable" onClick={() => copyToClipboard(config.email)}>
|
||||
{config.email || "—"}
|
||||
{config.email || "(not set)"}
|
||||
</code>
|
||||
<span className="setting-key">Password</span>
|
||||
<div className="password-field-inline">
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import type { BridgeStatus, BridgeStats, WsStatus } from "../types";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { BridgeStatus, BridgeStats } 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 {
|
||||
status: BridgeStatus | null;
|
||||
stats: BridgeStats;
|
||||
hasSavedSession: boolean;
|
||||
loading: boolean;
|
||||
logs: string[];
|
||||
onStart: (password?: string) => Promise<void>;
|
||||
onStop: () => Promise<void>;
|
||||
onClearLogs: () => void;
|
||||
}
|
||||
|
||||
function formatUptime(secs: number): string {
|
||||
@@ -33,17 +21,62 @@ function formatUptime(secs: number): string {
|
||||
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 logEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [logs]);
|
||||
|
||||
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 isStarting = status === "Starting";
|
||||
const isStopped = status === "Stopped" || isError(status);
|
||||
const errored = isError(status);
|
||||
const isStopped = status === "Stopped" || errored;
|
||||
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 () => {
|
||||
if (needsPassword) {
|
||||
@@ -56,50 +89,20 @@ export function Dashboard({ status, stats, hasSavedSession, loading, onStart, on
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
{isRunning && (
|
||||
<div className="status-hero running">
|
||||
<div className="hero-indicator">
|
||||
<span className="pulse-ring" />
|
||||
<span className="pulse-dot" />
|
||||
</div>
|
||||
<div className="hero-text">
|
||||
<strong>Bridge is running</strong>
|
||||
{stats.uptime_secs != null && (
|
||||
<span className="hero-uptime">Up {formatUptime(stats.uptime_secs)}</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="hero-action" onClick={onStop} disabled={loading}>
|
||||
{loading ? "Stopping..." : "Stop"}
|
||||
<div className={`status-bar ${accent}`}>
|
||||
<span className={`status-led ${led}`} />
|
||||
<div className="status-bar-text">
|
||||
<strong>{title}</strong>
|
||||
{subtitle && <span className="status-bar-sub">{subtitle}</span>}
|
||||
</div>
|
||||
{(isRunning || isStarting) && (
|
||||
<button className="status-action" onClick={onStop} disabled={loading}>
|
||||
{loading ? "Stopping…" : "Stop"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isStarting && (
|
||||
<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>
|
||||
)}
|
||||
{errored && <p className="error-text">{status.Error}</p>}
|
||||
|
||||
{isStopped && (
|
||||
<div className="start-section">
|
||||
@@ -120,38 +123,44 @@ export function Dashboard({ status, stats, hasSavedSession, loading, onStart, on
|
||||
onClick={handleStart}
|
||||
disabled={loading || (needsPassword && !password)}
|
||||
>
|
||||
{loading ? "Connecting..." : "Start Bridge"}
|
||||
{loading ? "Connecting…" : "Start Bridge"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stats-grid">
|
||||
<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>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="stat-value">
|
||||
{stats.uptime_secs != null ? formatUptime(stats.uptime_secs) : "--"}
|
||||
{stats.uptime_secs != null ? formatUptime(stats.uptime_secs) : "—"}
|
||||
</span>
|
||||
<span className="stat-label">Uptime</span>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<span className="ws-badge">
|
||||
{(() => {
|
||||
const b = wsBadge(stats.ws_status);
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={"ws-dot" + (b.pulsing ? " pulsing" : "")}
|
||||
style={{ background: b.color }}
|
||||
/>
|
||||
<span className="ws-text">{b.label}</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
<span className="stat-label">Realtime</span>
|
||||
</div>
|
||||
|
||||
<div className="dash-logs">
|
||||
<div className="dash-logs-header">
|
||||
<span className="dash-logs-title">Activity</span>
|
||||
{logs.length > 0 && (
|
||||
<button className="inline-btn" onClick={onClearLogs}>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="logs-container">
|
||||
{logs.length === 0 ? (
|
||||
<span className="logs-empty">Waiting for activity…</span>
|
||||
) : (
|
||||
logs.map((line, i) => (
|
||||
<div key={i} className="log-line">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user