feat: landing page + logo, dev-mode banner + sync cap, non-blocking sync with progress splash

This commit is contained in:
cesnimda
2026-06-30 16:58:35 +02:00
parent dcb939e4f2
commit fe5920919f
29 changed files with 552 additions and 39 deletions
+27 -4
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import GridLayout from 'react-grid-layout';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { AnalyticsApi, LayoutApi, ExportApi } from '../api/client.js';
import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js';
import SyncSplash from '../components/SyncSplash.jsx';
import {
HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
HeatmapWidget, AttachmentsWidget, StorageWidget
@@ -26,16 +27,37 @@ export default function Dashboard() {
const [data, setData] = useState(null);
const [layout, setLayout] = useState(DEFAULT_LAYOUT);
const [hidden, setHidden] = useState([]);
const [syncing, setSyncing] = useState(false);
const loadData = useCallback(() => {
AnalyticsApi.dashboard().then(setData).catch(() => {});
}, []);
useEffect(() => {
AnalyticsApi.dashboard().then(setData).catch(() => {});
LayoutApi.get().then((saved) => {
if (saved?.length) {
setLayout(saved.map((w) => ({ i: w.widgetKey, x: w.x, y: w.y, w: w.w, h: w.h })));
setHidden(saved.filter((w) => !w.visible).map((w) => w.widgetKey));
}
}).catch(() => {});
}, []);
// Decide whether to show the loading splash: if a sync is running, watch it;
// if the inbox has never been synced, kick one off automatically.
SyncApi.status().then((s) => {
if (s.isRunning) {
setSyncing(true);
} else if (!s.lastSuccessfulSyncUtc) {
SyncApi.incremental().then(() => setSyncing(true)).catch(loadData);
} else {
loadData();
}
}).catch(loadData);
}, [loadData]);
const onSyncDone = useCallback(() => {
setSyncing(false);
loadData();
}, [loadData]);
const persist = (nextLayout, nextHidden) => {
const dto = nextLayout.map((l, idx) => ({
@@ -69,6 +91,7 @@ export default function Dashboard() {
return (
<div className="dashboard">
{syncing && <SyncSplash onDone={onSyncDone} />}
<div className="toolbar">
<div className="widget-toggles">
{ALL_WIDGETS.map((k) => (
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Logo from '../components/Logo.jsx';
import { AuthApi, LOGIN_URL } from '../api/client.js';
const FEATURES = [
{ icon: '🔌', title: 'Gmail, synced locally', text: 'Secure Google sign-in, then a full + incremental sync of your inbox into your own PostgreSQL — with retry, backoff, and resume.' },
{ icon: '📊', title: 'Analytics dashboard', text: 'Inbox health score, top senders, volume over time, an activity heatmap, and attachment breakdowns — on draggable, resizable widgets.' },
{ icon: '🧹', title: 'Safe bulk cleanup', text: 'Archive, label, or delete in bulk — always with a preview and explicit confirmation before anything destructive happens.' },
{ icon: '✉️', title: 'Unsubscribe manager', text: 'Detects List-Unsubscribe headers, groups by sender, ranks what is safe to drop, and runs a confirmed unsubscribe queue.' },
{ icon: '🔎', title: 'Advanced search', text: 'PostgreSQL full-text search with Gmail-like syntax: from:, domain:, after:, is:unread, has:attachment.' },
{ icon: '🤖', title: 'Optional AI', text: 'Toggle a local (Ollama) or cloud (OpenAI) model to classify mail, summarise your inbox, and suggest cleanups. Never destructive.' }
];
export default function Landing() {
const [user, setUser] = useState(null);
const navigate = useNavigate();
useEffect(() => {
// Quietly check if already signed in to swap the CTA.
AuthApi.me().then(setUser).catch(() => setUser(null));
}, []);
const primaryCta = user
? <button className="cta" onClick={() => navigate('/app')}>Open your dashboard </button>
: <a className="cta" href={LOGIN_URL}>Sign in with Google</a>;
return (
<div className="landing">
<header className="landing-bar">
<Logo size={34} withWordmark />
<div className="spacer" />
{user
? <button className="ghost" onClick={() => navigate('/app')}>Dashboard</button>
: <a className="ghost" href={LOGIN_URL}>Log in</a>}
</header>
<section className="hero">
<Logo size={72} />
<h1>Take back control of your inbox.</h1>
<p className="sub">
InboxIntel syncs your Gmail into your own database, shows you what is really going on,
and helps you clean it up safely analytics, bulk actions, and one-click unsubscribe.
</p>
<div className="hero-cta">{primaryCta}</div>
<p className="fineprint">Google sign-in only. Your tokens are encrypted at rest and never logged.</p>
</section>
<section className="features">
{FEATURES.map((f) => (
<div className="feature" key={f.title}>
<div className="feature-icon">{f.icon}</div>
<h3>{f.title}</h3>
<p>{f.text}</p>
</div>
))}
</section>
<section className="closing">
<h2>Ready to dig in?</h2>
{primaryCta}
</section>
<footer className="landing-footer">
<Logo size={20} withWordmark />
<span className="muted">Gmail analytics, cleanup &amp; automation.</span>
</footer>
</div>
);
}