Files
waters-boards/server/public/js/app.js
T
christianandClaude Opus 5.5 fcf3805626
Deploy Waters Boards to NAS / deploy (push) Canceled after 0s
Waters Boards: self-hosted work management to replace monday.com
Boards with groups, items, subitems and 18 column types; table, kanban,
calendar, timeline, chart and files views; updates with @mentions,
replies and attachments; inbox, My work, search, automations; Google
sign-in restricted to company domains; live updates over SSE; a
monday.com API importer; phone layout and installable PWA; Docker +
Gitea Actions deploy to the NAS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:34:47 -04:00

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(() => {});