Brand assets in brand/ (the 512px mark, sizes for each surface, a multi-size .ico). Windows executables carry it as a compiled-in resource (rsrc_windows_amd64.syso from go-winres) so Explorer, the taskbar and the installer show it; installer/build.ps1 therefore uses a plain go build rather than wails build, which would add a second copy and fail the link. The tray icon is the mark with a state dot over its corner - a plain coloured circle read as a generic status light among other icons - rendered from the embedded PNG at 32px so it survives 150% scaling. The desktop app's login, setup and sidebar marks, the head-office web app's mark and favicon, and the engine dashboard's favicon are the same file. Also found while packaging: no wheel so far shipped static/, so the engine's own dashboard at :8010 on a Windows source install would have failed with a missing file. package-data now includes it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
180 lines
7.4 KiB
JavaScript
180 lines
7.4 KiB
JavaScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { api, isDesktop, message } from './bridge.js'
|
|
import { usePolled } from './hooks.js'
|
|
import * as Icon from './ui/icons.jsx'
|
|
import logo from './assets/loyaly-mark.png'
|
|
import Login from './views/Login.jsx'
|
|
import Setup from './views/Setup.jsx'
|
|
import Live from './views/Live.jsx'
|
|
import Customers from './views/Customers.jsx'
|
|
import Cameras from './views/Cameras.jsx'
|
|
|
|
// Three screens, and the trim is by AUDIENCE rather than by taste.
|
|
//
|
|
// This window runs on a PC behind a counter, and the person in front of it can
|
|
// act on exactly three things: is it working, who is this customer, and is the
|
|
// camera set up. Footfall and Sales answer a different person's questions - an
|
|
// owner comparing shops, who is not standing in one - and they now live on the
|
|
// head-office platform where a comparison across sites is even possible. A
|
|
// month-on-month chart on a shop PC was a report nobody there could act on,
|
|
// competing for the attention of somebody with a customer waiting.
|
|
//
|
|
// `cloud` marks a screen that cannot work without head office. A PC set up on
|
|
// its own hides those rather than showing a screen that can only ever fail:
|
|
// the customer record lives on the server, the cameras and what this PC is
|
|
// seeing do not.
|
|
const VIEWS = [
|
|
{ id: 'live', label: 'Live', Glyph: Icon.Live, View: Live },
|
|
{ id: 'customers', label: 'Customers', Glyph: Icon.People, View: Customers, cloud: true },
|
|
{ id: 'cameras', label: 'Cameras', Glyph: Icon.Camera, View: Cameras },
|
|
]
|
|
|
|
export default function App() {
|
|
const [session, setSession] = useState(null)
|
|
const [view, setView] = useState('live')
|
|
const [booting, setBooting] = useState(true)
|
|
// A standalone PC can join head office later. That is the same Setup screen,
|
|
// reached deliberately rather than because the app will not open otherwise.
|
|
const [linking, setLinking] = useState(false)
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
try { setSession(await api.session()) } catch { setSession(null) }
|
|
setBooting(false)
|
|
})()
|
|
}, [])
|
|
|
|
if (!isDesktop()) {
|
|
// The frontend can be served by `npm run dev` for styling work, where the
|
|
// Go bindings do not exist. Saying so beats a blank screen and a console
|
|
// error nobody will read.
|
|
return (
|
|
<div className="login"><div className="box">
|
|
<span className="mark"><img src={logo} alt="" /></span>
|
|
<h1>Behavision</h1>
|
|
<p className="lead">
|
|
This is the Behavision window running outside the app, so it has no
|
|
connection to the recognition engine. Launch the Behavision
|
|
application instead.
|
|
</p>
|
|
</div></div>
|
|
)
|
|
}
|
|
|
|
if (booting) return <div className="login"><p className="note">Starting…</p></div>
|
|
// Which shop this PC IS comes before who is standing at it. An installer on a
|
|
// brand new counter has a code and often no account yet, and every screen
|
|
// behind here is about a shop this PC does not have one of. Unless there is
|
|
// no head office at all, which is the other supported answer.
|
|
if (!session?.claimed && !session?.standalone) return <Setup onDone={setSession} />
|
|
if (!session?.standalone && !session?.logged_in) return <Login onDone={setSession} />
|
|
|
|
const views = VIEWS.filter(v => !v.cloud || !session.standalone)
|
|
const Current = views.find(v => v.id === view)?.View ?? Live
|
|
|
|
if (linking) {
|
|
return <Setup onDone={s => { setLinking(false); setSession(s) }}
|
|
onCancel={() => setLinking(false)} />
|
|
}
|
|
|
|
return (
|
|
<div className="shell">
|
|
<aside className="side">
|
|
<div className="brand">
|
|
<span className="mark"><img src={logo} alt="" /></span>
|
|
<div className="id">
|
|
<h1>Behavision</h1>
|
|
<p>{session.site_name || session.user?.client_name || 'This shop'}</p>
|
|
</div>
|
|
</div>
|
|
<nav className="nav">
|
|
{views.map(({ id, label, Glyph }) => (
|
|
<button key={id} onClick={() => setView(id)}
|
|
aria-current={id === view ? 'page' : undefined}>
|
|
<Glyph size={17} />{label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
<EngineBox />
|
|
<div className="who">
|
|
{session.standalone
|
|
? <>
|
|
<div className="id">
|
|
<b>On its own</b>
|
|
<span>No head office</span>
|
|
</div>
|
|
<button className="btn sm icon" title="Link to head office"
|
|
onClick={() => setLinking(true)}>
|
|
<Icon.Link size={15} />
|
|
</button>
|
|
</>
|
|
: <>
|
|
<div className="id">
|
|
<b>Signed in</b>
|
|
<span>{session.user?.email}</span>
|
|
</div>
|
|
<button className="btn sm icon" title="Sign out"
|
|
onClick={async () => setSession(await api.logout())}>
|
|
<Icon.Logout size={15} />
|
|
</button>
|
|
</>}
|
|
</div>
|
|
</aside>
|
|
<main className="main"><Current session={session} /></main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Always visible, because "is recognition actually running" is the question
|
|
// behind every other screen — an empty Live page means something completely
|
|
// different depending on the answer.
|
|
function EngineBox() {
|
|
const { data, reload } = usePolled(() => api.engineStatus(), 5000)
|
|
const [busy, setBusy] = useState(false)
|
|
const s = data ?? { state: 'stopped' }
|
|
|
|
const act = useCallback(async fn => {
|
|
setBusy(true)
|
|
try { await fn() } catch (e) { alert(message(e)) }
|
|
finally { setBusy(false); reload() }
|
|
}, [reload])
|
|
|
|
const running = s.state === 'running'
|
|
const cams = Object.values(s.cameras ?? {})
|
|
const up = cams.filter(Boolean).length
|
|
|
|
let tone = 'idle', text = 'Stopped'
|
|
// Reachable but not ours: somebody started the engine outside this app, or a
|
|
// previous copy is still up. Saying "Stopped" beside live camera feeds is the
|
|
// two-surfaces-disagreeing bug the tray exists to avoid - and it is exactly
|
|
// what this panel showed while recognition was visibly running.
|
|
if (!running && s.reachable) { tone = 'warn'; text = 'Running outside the app' }
|
|
else if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
|
|
else if (running && !s.reachable) { tone = 'warn'; text = 'Starting…' }
|
|
else if (running && cams.length === 0) { tone = 'warn'; text = 'No cameras' }
|
|
else if (running && up === 0) { tone = 'bad'; text = 'No camera connected' }
|
|
else if (running && up < cams.length) { tone = 'warn'; text = `${up} of ${cams.length} cameras` }
|
|
else if (running) { tone = 'ok'; text = `Watching ${up} camera${up === 1 ? '' : 's'}` }
|
|
|
|
return (
|
|
<div className="enginebox">
|
|
<div className="row">
|
|
<i className={`dot ${tone === 'ok' ? 'live' : tone}`} />
|
|
<span className="state">{text}</span>
|
|
</div>
|
|
{s.recognition_model && <span className="label">{s.recognition_model}</span>}
|
|
{s.error && <span className="label" style={{ color: 'var(--bad)' }}>{s.error}</span>}
|
|
<div className="actions">
|
|
<button className="btn sm" disabled={busy || running || s.reachable}
|
|
onClick={() => act(api.startEngine)}>
|
|
<Icon.Play size={13} />Start
|
|
</button>
|
|
<button className="btn sm" disabled={busy || !running}
|
|
onClick={() => act(api.stopEngine)}>
|
|
<Icon.Stop size={13} />Stop
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|