diff --git a/app/index.html b/app/index.html index 260e74d..2bdd97e 100644 --- a/app/index.html +++ b/app/index.html @@ -2,21 +2,363 @@ - - PI — your Princeton desk - + PI, your Princeton desk + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + -
+ +
+
+ +
+
+ + + +

opening your desk…

+
+
+
+
+ diff --git a/app/public/_headers b/app/public/_headers new file mode 100644 index 0000000..a04a344 --- /dev/null +++ b/app/public/_headers @@ -0,0 +1,39 @@ +# Workers Assets reads this file from the built asset directory (public/ is +# copied into dist/client). +# +# Rules are ADDITIVE: a request matching two rules gets both sets of headers, +# and a header set twice is joined with a comma. So the catch-all below must +# never set Cache-Control, or it would corrupt the values set here. The SPA +# shell keeps the platform default (max-age=0, must-revalidate), which is what +# it needs — deploys have to land immediately. + +# Content-hashed by Vite: the bytes behind these URLs can never change. +/assets/* + Cache-Control: public, max-age=31536000, immutable + +/fonts/* + Cache-Control: public, max-age=31536000, immutable + +# Named files that change only when we replace them. +/logos/* + Cache-Control: public, max-age=604800 +/favicon.svg + Cache-Control: public, max-age=604800 +/favicon.ico + Cache-Control: public, max-age=604800 +/apple-touch-icon.png + Cache-Control: public, max-age=604800 +/icon-192.png + Cache-Control: public, max-age=604800 +/icon-512.png + Cache-Control: public, max-age=604800 +/site.webmanifest + Cache-Control: public, max-age=86400 + +# Carries launch copy, so keep unfurlers no more than a day behind. +/og.png + Cache-Control: public, max-age=86400 + +/* + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin diff --git a/app/public/apple-touch-icon.png b/app/public/apple-touch-icon.png new file mode 100644 index 0000000..cdc4e5e Binary files /dev/null and b/app/public/apple-touch-icon.png differ diff --git a/app/public/favicon.ico b/app/public/favicon.ico new file mode 100644 index 0000000..c3ae3a9 Binary files /dev/null and b/app/public/favicon.ico differ diff --git a/app/public/favicon.svg b/app/public/favicon.svg new file mode 100644 index 0000000..b0c5b37 --- /dev/null +++ b/app/public/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/public/fonts/caveat-latin.woff2 b/app/public/fonts/caveat-latin.woff2 new file mode 100644 index 0000000..836dbda Binary files /dev/null and b/app/public/fonts/caveat-latin.woff2 differ diff --git a/app/public/icon-192.png b/app/public/icon-192.png new file mode 100644 index 0000000..ee6b0d9 Binary files /dev/null and b/app/public/icon-192.png differ diff --git a/app/public/icon-512.png b/app/public/icon-512.png new file mode 100644 index 0000000..793e004 Binary files /dev/null and b/app/public/icon-512.png differ diff --git a/app/public/og.png b/app/public/og.png new file mode 100644 index 0000000..fc6b0ca Binary files /dev/null and b/app/public/og.png differ diff --git a/app/public/robots.txt b/app/public/robots.txt new file mode 100644 index 0000000..a0db065 --- /dev/null +++ b/app/public/robots.txt @@ -0,0 +1,12 @@ +# The sign-in page is the only thing here a crawler can actually reach — +# everything below needs a Princeton session and answers 401 without one. +# Listing those paths keeps them out of crawl budget and out of search results. + +User-agent: * +Allow: /$ +Disallow: /chat/ +Disallow: /planner +Disallow: /agenda +Disallow: /apps +Disallow: /auth/ +Disallow: /agents/ diff --git a/app/public/site.webmanifest b/app/public/site.webmanifest new file mode 100644 index 0000000..1adb48c --- /dev/null +++ b/app/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "PI, your Princeton desk", + "short_name": "PI", + "description": "A Princeton chat that reads the TigerApps you switch on.", + "start_url": "/", + "display": "standalone", + "background_color": "#fdfcf9", + "theme_color": "#fdfcf9", + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/app/src/client/App.tsx b/app/src/client/App.tsx index a77fd62..e59e32c 100644 --- a/app/src/client/App.tsx +++ b/app/src/client/App.tsx @@ -1,37 +1,89 @@ -import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MouseEvent, +} from "react"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import { IconMenu, PiMark } from "./components/Icons"; import { Sidebar, type Route } from "./components/Sidebar"; -import { useIdentity, type Identity } from "./lib/auth"; -import { newChatId, toSettings, useChats, usePrefs } from "./lib/store"; -import { AgendaPage } from "./pages/AgendaPage"; -import { AppsPage } from "./pages/AppsPage"; -import { ChatPage } from "./pages/ChatPage"; -import { PlannerPage } from "./pages/PlannerPage"; +import { + signIn, + takeReturnPath, + useIdentity, + type Identity, +} from "./lib/auth"; +import { + ensureChat, + GENERAL_CHAT_ID, + GENERAL_CHAT_TITLE, + newChatId, + toSettings, + useChats, + usePrefs, +} from "./lib/store"; import { SignInPage } from "./pages/SignInPage"; +import "./styles/shell.css"; + +// Each destination is its own chunk: the sign-in page and the shell are the +// only things a first visit has to download. +const ChatPage = lazy(() => + import("./pages/ChatPage").then((m) => ({ default: m.ChatPage })) +); +const PlannerPage = lazy(() => + import("./pages/PlannerPage").then((m) => ({ default: m.PlannerPage })) +); +const AgendaPage = lazy(() => + import("./pages/AgendaPage").then((m) => ({ default: m.AgendaPage })) +); +const AppsPage = lazy(() => + import("./pages/AppsPage").then((m) => ({ default: m.AppsPage })) +); + +const BASE_TITLE = "PI, your Princeton desk"; + +const PAGE_TITLES: Record = { + planner: "Planner", + agenda: "Agenda", + apps: "My apps", + notfound: "Page not found", +}; function parseRoute(pathname: string): Route { const chat = pathname.match(/^\/chat\/([\w-]+)$/); if (chat) return { page: "chat", chatId: chat[1] }; + if (pathname === "/") return { page: "chat", chatId: null }; if (pathname === "/planner") return { page: "planner" }; if (pathname === "/agenda") return { page: "agenda" }; if (pathname === "/apps") return { page: "apps" }; - return { page: "chat", chatId: null }; + return { page: "notfound" }; } export function App() { - const auth = useIdentity(); + const { auth, revalidate } = useIdentity(); - if (auth.status === "loading") { - return ( -
- opening your desk… -
- ); + if (auth.status === "loading") return ; + if (auth.status === "unreachable") { + return ; } if (auth.status === "anon") return ; - return ; + return ( + + ); } -function Desk({ identity }: { identity: Identity }) { +function Desk({ + identity, + lapsed, +}: { + identity: Identity; + /** The session ran out while this tab was open. */ + lapsed: boolean; +}) { const [route, setRoute] = useState(() => parseRoute(location.pathname) ); @@ -39,6 +91,11 @@ function Desk({ identity }: { identity: Identity }) { const [sidebarOpen, setSidebarOpen] = useState(false); const prefs = usePrefs(identity.netid); const chats = useChats(identity.netid); + const narrow = useNarrow(); + const mainRef = useRef(null); + const menuRef = useRef(null); + const takeFocus = useRef(false); + const returnFocus = useRef(false); const settings = useMemo( () => toSettings(identity.netid, prefs), [identity.netid, prefs] @@ -52,69 +109,335 @@ function Desk({ identity }: { identity: Identity }) { if (next.page === "chat" && next.chatId === null && !replace) { setDraftId(newChatId()); } + // A replace is the chat page pinning its own URL mid-send; that must never + // pull focus out of the composer. + if (!replace) takeFocus.current = true; setRoute(next); setSidebarOpen(false); }, []); + // Dismissing the drawer hands focus back to the button that opened it. It + // waits for the render because closing is also what lifts `inert` off main. + const closeSidebar = useCallback(() => { + returnFocus.current = true; + setSidebarOpen(false); + }, []); + + useEffect(() => { + if (sidebarOpen || !returnFocus.current) return; + returnFocus.current = false; + menuRef.current?.focus(); + }, [sidebarOpen]); + useEffect(() => { const onPop = () => setRoute(parseRoute(location.pathname)); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); + // Sign-in always lands on "/", so replay the page the student asked for. + useEffect(() => { + const parked = takeReturnPath(); + if (parked && parked !== location.pathname + location.search) { + navigate(parked, true); + } + }, [navigate]); + + useEffect(() => { + if (!takeFocus.current) return; + takeFocus.current = false; + mainRef.current?.focus({ preventScroll: true }); + }, [route]); + + // The general chat is a constant, not a minted id; give it a row the first + // time it's opened so it carries a real timestamp afterwards. + useEffect(() => { + if (route.page === "chat" && route.chatId === GENERAL_CHAT_ID) { + ensureChat(identity.netid, { + id: GENERAL_CHAT_ID, + title: GENERAL_CHAT_TITLE, + at: Date.now(), + }); + } + }, [route, identity.netid]); + + useEffect(() => { + let head: string | null = null; + if (route.page === "chat") { + if (route.chatId === GENERAL_CHAT_ID) head = GENERAL_CHAT_TITLE; + else if (route.chatId) { + head = chats.find((c) => c.id === route.chatId)?.title || "Chat"; + } + } else { + head = PAGE_TITLES[route.page] ?? null; + } + document.title = head ? `${head} · PI` : BASE_TITLE; + }, [route, chats]); + + useEffect(() => { + if (!sidebarOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") closeSidebar(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [sidebarOpen, closeSidebar]); + + useRouteWarmup(); + const chatId = route.page === "chat" ? (route.chatId ?? draftId) : null; return ( -
- {sidebarOpen && ( -
setSidebarOpen(false)} /> - )} - -
-
+ <> + + skip to the conversation + +
+ {sidebarOpen && ( - PI -
- {route.page === "chat" && chatId && ( - - opening your desk… -
- } - > - - - )} - {route.page === "planner" && ( - - )} - {route.page === "agenda" && ( - - )} - {route.page === "apps" && ( - + className="scrim" + aria-label="Close PI's sections" + onClick={closeSidebar} + /> )} + +
+
+ + + + + PI +
+ {/* One destination failing keeps the sidebar alive, so the student + can walk to another chat instead of reloading the world. The key + clears the error as soon as they do. */} + + {route.page === "chat" && chatId && ( + }> + + + )} + {route.page === "planner" && ( + }> + + + )} + {route.page === "agenda" && ( + }> + + + )} + {route.page === "apps" && ( + }> + + + )} + {route.page === "notfound" && } + +
+
+ {lapsed && } + + ); +} + +/** True while the sidebar is a drawer rather than a column. */ +function useNarrow(): boolean { + const [narrow, setNarrow] = useState( + () => window.matchMedia("(max-width: 820px)").matches + ); + useEffect(() => { + const mq = window.matchMedia("(max-width: 820px)"); + const onChange = (e: MediaQueryListEvent) => setNarrow(e.matches); + setNarrow(mq.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); + return narrow; +} + +/** Pull the other route chunks down once the desk is quiet. */ +function useRouteWarmup(): void { + useEffect(() => { + const warm = () => { + void import("./pages/ChatPage"); + void import("./pages/AgendaPage"); + void import("./pages/AppsPage"); + void import("./pages/PlannerPage"); + }; + const idle = ( + window as Window & { + requestIdleCallback?: (cb: () => void, o?: { timeout: number }) => number; + cancelIdleCallback?: (id: number) => void; + } + ).requestIdleCallback; + if (!idle) { + const t = window.setTimeout(warm, 2500); + return () => clearTimeout(t); + } + const id = idle(warm, { timeout: 4000 }); + return () => window.cancelIdleCallback?.(id); + }, []); +} + +/** + * The auth check's loading state. This is the same tree index.html paints + * before the bundle lands, so React taking over changes nothing on screen. + */ +function BootShell() { + return ( +
+ + ); +} + +/** Chat-shaped placeholder: two turns and the composer already in place. */ +function ChatSkeleton() { + return ( +
+
+
+ +
+
+ + + +
+
+
+
+ ); +} + +function PageSkeleton({ label }: { label: string }) { + return ( +
+
+
+
+
+
+
+ ); +} + +function NotFound({ + navigate, +}: { + navigate: (path: string, replace?: boolean) => void; +}) { + function home(e: MouseEvent) { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; + e.preventDefault(); + navigate("/"); + } + return ( +
+
+ {/* Every other route heads its page; this one should too, so the + outline isn't a hole for anyone skimming by heading. */} +

nothing filed under that address.

+

+ The link is either mistyped or points at a page PI used to have. +

+
+ + Back to chat + +
+ ); +} + +/** + * Sessions last a week and don't slide, so they do run out mid-tab. The desk + * stays on screen behind this — nothing was lost, and signing back in returns + * to the very same page. + */ +function SessionLapse() { + const cta = useRef(null); + useEffect(() => cta.current?.focus(), []); + return ( +
+
+ + + +

Your Princeton sign-in ran out.

+

+ Nothing is lost. Sign back in and PI puts you down on this same page. +

+
); diff --git a/app/src/client/components/Composer.tsx b/app/src/client/components/Composer.tsx index 1fcead9..5fa730e 100644 --- a/app/src/client/components/Composer.tsx +++ b/app/src/client/components/Composer.tsx @@ -1,4 +1,7 @@ import { useEffect, useRef, useState } from "react"; +import { IconSend, IconStop } from "./Icons"; + +const MAX_HEIGHT = 160; export function Composer({ onSend, @@ -18,19 +21,37 @@ export function Composer({ accessory?: React.ReactNode; }) { const [text, setText] = useState(initialText); + /** True for a beat after Enter lands while PI is still writing. */ + const [blocked, setBlocked] = useState(false); const ref = useRef(null); + function grow(el: HTMLTextAreaElement) { + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, MAX_HEIGHT)}px`; + } + useEffect(() => { if (initialText && ref.current) { ref.current.focus(); - ref.current.style.height = "auto"; - ref.current.style.height = `${Math.min(ref.current.scrollHeight, 160)}px`; + grow(ref.current); } }, [initialText]); + // The nudge is a one-shot; clear it so a second blocked Enter re-fires it. + useEffect(() => { + if (!blocked) return; + const t = setTimeout(() => setBlocked(false), 900); + return () => clearTimeout(t); + }, [blocked]); + function submit() { const t = text.trim(); - if (!t || busy) return; + if (!t) return; + // Mid-turn, the draft stays exactly where the student left it. + if (busy) { + setBlocked(true); + return; + } setText(""); if (ref.current) ref.current.style.height = "auto"; onSend(t); @@ -38,18 +59,19 @@ export function Composer({ return (
-
+
{accessory}