App.tsx
⎇
Raw
1import { createResource, createSignal, Show, onCleanup, type JSX } from "solid-js";
2import { A, Route, Router, useNavigate } from "@solidjs/router";
3import { api, ApiError, decodePolyline, UNAUTHORIZED, type Me } from "./api";
4import { createLiveStore } from "./live";
5import { SessionContext } from "./session";
6import MapPage from "./MapPage";
7import Settings from "./Settings";
8
9const TRAIL_SECONDS = 24 * 3600;
10const TRAIL_REFRESH_MS = 60_000;
11
12export default function App() {
13 return (
14 <Router root={Shell}>
15 <Route path="/" component={MapPage} />
16 <Route path="/settings" component={Settings} />
17 <Route path="*" component={NotFound} />
18 </Router>
19 );
20}
21
22function NotFound() {
23 return (
24 <main class="center">
25 <p class="muted">no such page</p>
26 <A href="/">back to the map</A>
27 </main>
28 );
29}
30
31/**
32 * The router root: session, navigation, and the one polling loop.
33 *
34 * Signing out is not a redirect. The signed-out shell renders the login form in
35 * place, so a deep link to /settings survives it — you land where you were
36 * heading rather than back at the map.
37 */
38function Shell(props: { children?: JSX.Element }) {
39 // undefined while the initial /api/me is in flight, null when signed out.
40 const [me, setMe] = createSignal<Me | null | undefined>(undefined);
41 api.me()
42 .then(setMe)
43 .catch(() => setMe(null));
44
45 return (
46 <Show when={me() !== undefined} fallback={<div class="center">loading…</div>}>
47 <Show when={me()} fallback={<Login onSignedIn={setMe} />} keyed>
48 {(user) => (
49 <SignedIn me={user} onSignedOut={() => setMe(null)}>
50 {props.children}
51 </SignedIn>
52 )}
53 </Show>
54 </Show>
55 );
56}
57
58function SignedIn(props: { me: Me; onSignedOut: () => void; children?: JSX.Element }) {
59 const live = createLiveStore(props.onSignedOut);
60 const [selected, setSelected] = createSignal<number | null>(props.me.id);
61
62 // The trail for whoever is selected. A 403 means the share does not grant
63 // trail visibility, which is a normal answer, not an error to surface.
64 const [trail, { refetch }] = createResource(selected, async (id) => {
65 const to = live.serverTime();
66 try {
67 const res = await api.track(id, to - TRAIL_SECONDS, to);
68 return decodePolyline(res.polyline);
69 } catch (e) {
70 if (e instanceof ApiError && (e.status === 403 || e.status === UNAUTHORIZED)) return null;
71 throw e;
72 }
73 });
74 const timer = setInterval(() => void refetch(), TRAIL_REFRESH_MS);
75 onCleanup(() => clearInterval(timer));
76
77 async function signOut() {
78 await api.logout().catch(() => {});
79 props.onSignedOut();
80 }
81
82 return (
83 <SessionContext.Provider
84 value={{
85 me: props.me,
86 people: live.people,
87 order: live.order,
88 serverTime: live.serverTime,
89 selected,
90 select: setSelected,
91 trail: () => trail() ?? null,
92 }}
93 >
94 <div class="shell">
95 <header>
96 <strong>opentracker</strong>
97 <nav>
98 <A href="/" end activeClass="active">
99 map
100 </A>
101 <A href="/settings" activeClass="active">
102 settings
103 </A>
104 </nav>
105 <span class="who">{props.me.display_name}</span>
106 <button onClick={signOut}>sign out</button>
107 </header>
108 <Show when={live.error()}>{(msg) => <div class="banner">{msg()}</div>}</Show>
109 {props.children}
110 </div>
111 </SessionContext.Provider>
112 );
113}
114
115function Login(props: { onSignedIn: (me: Me) => void }) {
116 const [username, setUsername] = createSignal("");
117 const [password, setPassword] = createSignal("");
118 const [error, setError] = createSignal<string | null>(null);
119 const [busy, setBusy] = createSignal(false);
120 const navigate = useNavigate();
121
122 async function submit(e: Event) {
123 e.preventDefault();
124 setBusy(true);
125 setError(null);
126 try {
127 const { user } = await api.login(username(), password());
128 // A stale bookmark to a route that no longer exists would otherwise sign
129 // in and land on the 404 page.
130 if (window.location.pathname === "/login") navigate("/", { replace: true });
131 props.onSignedIn(user);
132 } catch (e) {
133 setError(e instanceof Error ? e.message : String(e));
134 } finally {
135 setBusy(false);
136 }
137 }
138
139 return (
140 <form class="center card login" onSubmit={submit}>
141 <h1>opentracker</h1>
142 <input placeholder="username" autocomplete="username" value={username()} onInput={(e) => setUsername(e.currentTarget.value)} />
143 <input type="password" placeholder="password" autocomplete="current-password" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} />
144 <Show when={error()}>{(msg) => <p class="error">{msg()}</p>}</Show>
145 <button disabled={busy()}>{busy() ? "signing in…" : "sign in"}</button>
146 </form>
147 );
148}
149