139 lines
7.2 KiB
JavaScript
139 lines
7.2 KiB
JavaScript
// Entry point: sign-in gate, app shell, routing, overlays, mobile tab bar.
|
|||
|
|
import { html, render, useState, useEffect, useRef } from '../vendor/preact-htm.mjs';
|
||
|
|
import { api, connectEvents } from './api.js';
|
||
|
|
import { state, update, useStore, navigate, loadBootstrap, applyTheme } from './store.js';
|
||
|
|
import { Icon, PopoverHost, ModalHost, Spinner, useMobile } from './ui.js';
|
||
|
|
import { Sidebar } from './sidebar.js';
|
||
|
|
import { BoardPage } from './board.js';
|
||
|
|
import { HomePage, MyWorkPage, InboxPage, SearchPage, PeoplePage, AdminPage, WorkspacePage } from './pages.js';
|
||
|
|
|
||
|
|
applyTheme();
|
||
|
|
|
||
|
|
function App() {
|
||
|
|
const s = useStore();
|
||
|
|
const [phase, setPhase] = useState('loading'); // loading | signin | ready
|
||
|
|
useEffect(() => {
|
||
|
|
loadBootstrap().then(() => { setPhase('ready'); connectEvents(); }).catch(() => setPhase('signin'));
|
||
|
|
const out = () => setPhase('signin');
|
||
|
|
window.addEventListener('wb:signed-out', out);
|
||
|
|
return () => window.removeEventListener('wb:signed-out', out);
|
||
|
|
}, []);
|
||
|
|
// Keyboard: "/" or Cmd/Ctrl+K opens search
|
||
|
|
useEffect(() => {
|
||
|
|
const fn = (e) => {
|
||
|
|
if (/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName) || document.activeElement?.isContentEditable) return;
|
||
|
|
if ((e.key === 'k' && (e.metaKey || e.ctrlKey)) || e.key === '/') { e.preventDefault(); navigate('/search'); }
|
||
|
|
};
|
||
|
|
document.addEventListener('keydown', fn);
|
||
|
|
return () => document.removeEventListener('keydown', fn);
|
||
|
|
}, []);
|
||
|
|
if (phase === 'loading') return html`<div class="splash"><span class="logo-mark big"><i /><i /><i /></span></div>`;
|
||
|
|
if (phase === 'signin') return html`<${SignIn} onDone=${async () => { await loadBootstrap(); connectEvents(); setPhase('ready'); }} />`;
|
||
|
|
return html`<div class="app ${s.sidebarCollapsed ? 'nav-collapsed' : ''}">
|
||
|
|
<${Sidebar} />
|
||
|
|
<main class="main"><${Route} /></main>
|
||
|
|
<${MobileTabs} />
|
||
|
|
<${PopoverHost} />
|
||
|
|
<${ModalHost} />
|
||
|
|
<${Toasts} />
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function Route() {
|
||
|
|
const r = useStore().route;
|
||
|
|
useEffect(() => {
|
||
|
|
const b = r.boardId && state.boards.find((x) => x.id === r.boardId);
|
||
|
|
document.title = b ? `${b.name} · Waters Boards` : 'Waters Boards';
|
||
|
|
}, [r.name, r.boardId, state.boards.length]);
|
||
|
|
switch (r.name) {
|
||
|
|
case 'board': return html`<${BoardPage} key=${r.boardId} boardId=${r.boardId} viewId=${r.viewId} itemId=${r.itemId} />`;
|
||
|
|
case 'my-work': return html`<${MyWorkPage} key=${r.q?.user || 'me'} />`;
|
||
|
|
case 'inbox': return html`<${InboxPage} />`;
|
||
|
|
case 'search': return html`<${SearchPage} />`;
|
||
|
|
case 'people': return html`<${PeoplePage} />`;
|
||
|
|
case 'admin': return html`<${AdminPage} />`;
|
||
|
|
case 'workspace': return html`<${WorkspacePage} workspaceId=${r.workspaceId} />`;
|
||
|
|
default: return html`<${HomePage} />`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function MobileTabs() {
|
||
|
|
const s = useStore();
|
||
|
|
const mobile = useMobile();
|
||
|
|
if (!mobile || s.route.itemId) return null;
|
||
|
|
const r = s.route.name;
|
||
|
|
const Tab = ({ icon, label, to, active, badge, onClick }) => html`<button class="mtab ${active ? 'active' : ''}" onClick=${onClick || (() => navigate(to))}>
|
||
|
|
<${Icon} name=${icon} size=${22} />${badge ? html`<span class="rail-badge">${badge > 99 ? '99+' : badge}</span>` : null}<span>${label}</span></button>`;
|
||
|
|
return html`<nav class="mobile-tabs">
|
||
|
|
<${Tab} icon="home" label="Home" to="/" active=${r === 'home'} />
|
||
|
|
<${Tab} icon="work" label="My work" to="/my-work" active=${r === 'my-work'} />
|
||
|
|
<${Tab} icon="board" label="Boards" active=${r === 'board' || s.sidebarOpen} onClick=${() => update({ sidebarOpen: true })} />
|
||
|
|
<${Tab} icon="bell" label="Inbox" to="/inbox" active=${r === 'inbox'} badge=${s.unread} />
|
||
|
|
<${Tab} icon="search" label="Search" to="/search" active=${r === 'search'} />
|
||
|
|
</nav>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function Toasts() {
|
||
|
|
const s = useStore();
|
||
|
|
return html`<div class="toasts">${s.toasts.map((t) => html`<div key=${t.id} class="toast ${t.kind}">${t.text}
|
||
|
|
${t.action ? html`<button class="link" onClick=${t.action.onClick}>${t.action.label}</button>` : null}</div>`)}</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Sign in ─────────────────────────────────────────────
|
||
|
|
function SignIn({ onDone }) {
|
||
|
|
const [cfg, setCfg] = useState(null);
|
||
|
|
const [err, setErr] = useState('');
|
||
|
|
const [mode, setMode] = useState('google');
|
||
|
|
const [email, setEmail] = useState('');
|
||
|
|
const [password, setPassword] = useState('');
|
||
|
|
const btn = useRef();
|
||
|
|
useEffect(() => { api.get('/api/auth/config').then(setCfg).catch(() => setCfg({})); }, []);
|
||
|
|
useEffect(() => {
|
||
|
|
if (!cfg?.googleClientId || mode !== 'google') return;
|
||
|
|
let tries = 0;
|
||
|
|
const init = () => {
|
||
|
|
if (!window.google?.accounts?.id) { if (tries++ < 50) setTimeout(init, 100); else setErr("Couldn't load Google sign-in. Check your connection."); return; }
|
||
|
|
window.google.accounts.id.initialize({
|
||
|
|
client_id: cfg.googleClientId,
|
||
|
|
callback: async (resp) => {
|
||
|
|
setErr('');
|
||
|
|
try { await api.post('/api/auth/google', { credential: resp.credential }); onDone(); }
|
||
|
|
catch (e) { setErr(e.message); }
|
||
|
|
},
|
||
|
|
hd: cfg.allowedDomains?.length === 1 ? cfg.allowedDomains[0] : undefined,
|
||
|
|
ux_mode: 'popup',
|
||
|
|
});
|
||
|
|
window.google.accounts.id.renderButton(btn.current, { theme: 'outline', size: 'large', text: 'continue_with', shape: 'pill', width: 280 });
|
||
|
|
};
|
||
|
|
init();
|
||
|
|
}, [cfg, mode]);
|
||
|
|
const submitPassword = async (e) => {
|
||
|
|
e.preventDefault(); setErr('');
|
||
|
|
try { await api.post(mode === 'dev' ? '/api/auth/dev' : '/api/auth/password', { email, password, name: email.split('@')[0] }); onDone(); }
|
||
|
|
catch (e2) { setErr(e2.message); }
|
||
|
|
};
|
||
|
|
if (!cfg) return html`<div class="splash"><${Spinner} /></div>`;
|
||
|
|
return html`<div class="signin">
|
||
|
|
<div class="signin-card">
|
||
|
|
<span class="logo-mark big"><i /><i /><i /></span>
|
||
|
|
<h1>Waters Boards</h1>
|
||
|
|
<p class="muted">Sign in with your ${cfg.allowedDomains?.map((d) => '@' + d).join(' or ')} account</p>
|
||
|
|
${mode === 'google' ? html`
|
||
|
|
${cfg.googleClientId ? html`<div class="gbtn" ref=${btn} />` : html`<p class="warn-note">Google sign-in isn't set up on this server yet (GOOGLE_CLIENT_ID is empty). An admin can still sign in with a password.</p>`}
|
||
|
|
${cfg.passwordLogin ? html`<button class="link small" onClick=${() => setMode('password')}>Admin sign-in with password</button>` : null}
|
||
|
|
${cfg.devLogin ? html`<button class="link small" onClick=${() => setMode('dev')}>Developer sign-in (local only)</button>` : null}
|
||
|
|
` : html`<form class="signin-form" onSubmit=${submitPassword}>
|
||
|
|
<input class="input" type="email" placeholder="Email" value=${email} onInput=${(e) => setEmail(e.target.value)} autofocus required />
|
||
|
|
${mode === 'password' ? html`<input class="input" type="password" placeholder="Password" value=${password} onInput=${(e) => setPassword(e.target.value)} required />` : null}
|
||
|
|
<button class="btn primary">Sign in</button>
|
||
|
|
<button type="button" class="link small" onClick=${() => setMode('google')}>Back to Google sign-in</button>
|
||
|
|
</form>`}
|
||
|
|
${err ? html`<p class="error-note">${err}</p>` : null}
|
||
|
|
</div>
|
||
|
|
</div>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
render(html`<${App} />`, document.getElementById('app'));
|
||
|
|
|
||
|
|
if ('serviceWorker' in navigator && location.protocol === 'https:') navigator.serviceWorker.register('/sw.js').catch(() => {});
|