Make the app an installable PWA with phone layout and Web Push
Add a generated manifest, icon set, offline page and service worker that never caches authenticated HTML, plus install and update UI. Turn the rail into a drawer below md with container-query internals, dvh and safe-area handling, and reconnect the socket after visibility/network changes. Add Web Push subscriptions, presence-aware delivery, notification settings and tests.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { createContext } from "react";
|
||||
|
||||
export type RailDrawerContextObject = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The rail is a slide-over below `md`, an inline rail above it. The
|
||||
* pane headers' menu button and the rail itself share this.
|
||||
*/
|
||||
export const RailDrawerContext = createContext<RailDrawerContextObject>({
|
||||
open: false,
|
||||
setOpen: () => {},
|
||||
});
|
||||
@@ -45,14 +45,14 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) {
|
||||
aria-current={selected ? "page" : undefined}
|
||||
title={bot.name}
|
||||
className={twMerge(
|
||||
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal",
|
||||
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal",
|
||||
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
|
||||
selected && "bg-ink/5 hover:bg-ink/5",
|
||||
)}
|
||||
>
|
||||
<BotAvatar image_media_id={bot.image_media_id} hue={hue} face={(bot.face || "idle") as BotFace} />
|
||||
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
|
||||
<Stack className="hidden @rail:flex grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{bot.name}
|
||||
</Span>
|
||||
@@ -60,7 +60,7 @@ export default function BotRow({ bot, href, selected, subtitle }: Props) {
|
||||
{snippet}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Stack className="hidden lg:flex shrink-0 self-stretch items-end justify-start gap-1.5! pt-1.5">
|
||||
<Stack className="hidden @rail:flex shrink-0 self-stretch items-end justify-start gap-1.5! pt-1.5">
|
||||
{at ? (
|
||||
<Span className="text-[11px] leading-4 text-ink-secondary">
|
||||
{relativeTime({ at: Number(at) })}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { Share, SquarePlus } from "lucide-react";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const Steps = [
|
||||
{
|
||||
Icon: Share,
|
||||
title: "Open the Share menu",
|
||||
description: "In Safari it is the square with an arrow pointing up, in the toolbar.",
|
||||
},
|
||||
{
|
||||
Icon: SquarePlus,
|
||||
title: "Choose Add to Home Screen",
|
||||
description: "Scroll the list if it is not there. Then tap Add.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Safari on iOS has no install event, so installation is the Share menu.
|
||||
*/
|
||||
export default function InstallAppModal({ open, setOpen }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={(next) => setOpen(typeof next == "function" ? next(open) : next)}
|
||||
className="max-w-lg bg-paper p-6"
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||
Install {SiteData["SiteName"]}
|
||||
</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
Installed, it opens in its own window and can send
|
||||
notifications. Safari installs it from the Share menu.
|
||||
</Span>
|
||||
</Stack>
|
||||
<Stack className="w-full mt-5 gap-4! items-stretch">
|
||||
{Steps.map((step) => (
|
||||
<Row
|
||||
key={step.title}
|
||||
className="gap-3! flex-nowrap items-start"
|
||||
>
|
||||
<Row className="size-9 shrink-0 items-center justify-center rounded-default bg-sunken">
|
||||
<step.Icon size={17} aria-hidden className="text-ink" />
|
||||
</Row>
|
||||
<Stack className="gap-0.5! min-w-0">
|
||||
<Span className="text-sm font-bold leading-5 text-ink">
|
||||
{step.title}
|
||||
</Span>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{step.description}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Row>
|
||||
))}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { WifiOff } from "lucide-react";
|
||||
|
||||
/**
|
||||
* A hairline strip while the browser is offline. Nothing is queued for
|
||||
* later, so it says messages won't send until the connection is back.
|
||||
*/
|
||||
export default function OfflineBanner() {
|
||||
return (
|
||||
<Row
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="w-full shrink-0 gap-2! flex-nowrap items-center justify-center px-4 py-2 bg-sunken border-0 border-b border-solid border-hairline"
|
||||
>
|
||||
<WifiOff size={14} aria-hidden className="shrink-0 text-ink-secondary" />
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
You're offline. Messages won't send until you reconnect.
|
||||
</Span>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export default function RailAddButton() {
|
||||
variant="ghost"
|
||||
color="text"
|
||||
onClick={openAdd}
|
||||
className="hidden lg:flex shrink-0 p-1.5! rounded-default-sm text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
|
||||
className="hidden @rail:flex shrink-0 p-1.5! rounded-default-sm text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
|
||||
>
|
||||
<Plus size={18} aria-hidden />
|
||||
</Button>
|
||||
|
||||
@@ -21,12 +21,12 @@ export default function RailBackLink({
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
className="flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
className="flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal text-ink hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0">
|
||||
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto @rail:mx-0">
|
||||
<ArrowLeft size={18} aria-hidden />
|
||||
</Span>
|
||||
<Span className="hidden lg:block min-w-0 truncate text-sm font-bold leading-5 text-ink">
|
||||
<Span className="hidden @rail:block min-w-0 truncate text-sm font-bold leading-5 text-ink">
|
||||
{title}
|
||||
</Span>
|
||||
</Link>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function RailBotsSection({ title, workers, selected_id, hrefFor,
|
||||
<BotRow key={bot.id} bot={bot} href={hrefFor(bot)} selected={bot.id == selected_id} />
|
||||
))}
|
||||
{workers.length ? null : (
|
||||
<Span className="hidden lg:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
|
||||
<Span className="hidden @rail:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
|
||||
{empty}
|
||||
</Span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { Menu } from "lucide-react";
|
||||
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
||||
import { RailDrawerContext } from "../(data)/rail-drawer-context";
|
||||
|
||||
/**
|
||||
* Opens the rail's drawer below `md`; the inline rail has no need for it.
|
||||
*/
|
||||
export default function RailDrawerButton() {
|
||||
const { setOpen } = useContext(RailDrawerContext);
|
||||
|
||||
return (
|
||||
<span className="inline-flex md:hidden">
|
||||
<HeaderIconButton
|
||||
icon={Menu}
|
||||
title="Open the bots rail"
|
||||
onClick={() => setOpen(true)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -13,13 +13,13 @@ type Props = {
|
||||
|
||||
export default function RailHeader({ show_add = true }: Props) {
|
||||
return (
|
||||
<Row className="h-[72px] shrink-0 gap-0! flex-nowrap items-start justify-center lg:justify-between px-0 lg:pl-5 lg:pr-6 pt-5">
|
||||
<Row className="h-[72px] shrink-0 gap-0! flex-nowrap items-start justify-center @rail:justify-between px-0 @rail:pl-5 @rail:pr-6 pt-5">
|
||||
<Link
|
||||
href="/admin/bots"
|
||||
className="border-0 mt-1 rounded-default-xs hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
<Wordmark height={15} className="hidden lg:block" />
|
||||
<LogoIcon icon_size={28} img_props={{ className: "lg:hidden" }} />
|
||||
<Wordmark height={15} className="hidden @rail:block" />
|
||||
<LogoIcon icon_size={28} img_props={{ className: "@rail:hidden" }} />
|
||||
</Link>
|
||||
{show_add ? <RailAddButton /> : null}
|
||||
</Row>
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function RailPagesList({ pathname }: Props) {
|
||||
return (
|
||||
<>
|
||||
<RailBackLink />
|
||||
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
<Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
{RailPages.filter((page) => is_super_admin || !page.super_admin_only).map((page) => (
|
||||
<RailSettingsLink
|
||||
key={page.url}
|
||||
|
||||
@@ -18,13 +18,13 @@ type Props = {
|
||||
export default function RailSectionLabel({ title, action }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Row className="hidden lg:flex shrink-0 h-8 mt-3 gap-2! flex-nowrap items-center justify-between px-3">
|
||||
<Row className="hidden @rail:flex shrink-0 h-8 mt-3 gap-2! flex-nowrap items-center justify-between px-3">
|
||||
<Span className="text-[11px] font-bold uppercase tracking-wide leading-4 text-ink-secondary">
|
||||
{title}
|
||||
</Span>
|
||||
{action}
|
||||
</Row>
|
||||
<Divider className="lg:hidden shrink-0 mx-1 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
<Divider className="@rail:hidden shrink-0 mx-1 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,15 +28,15 @@ export default function RailSeeAllRow({ hidden, label, onOpen }: Props) {
|
||||
variant="ghost"
|
||||
color="text"
|
||||
onClick={onOpen}
|
||||
className="flex shrink-0 h-10 w-full items-center justify-center lg:justify-start gap-3! rounded-default px-1! lg:px-3! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
|
||||
className="flex shrink-0 h-10 w-full items-center justify-center @rail:justify-start gap-3! rounded-default px-1! @rail:px-3! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
|
||||
>
|
||||
<Ellipsis size={18} aria-hidden className="lg:hidden" />
|
||||
<Row className="hidden lg:flex shrink-0 gap-1! flex-nowrap items-center">
|
||||
<Ellipsis size={18} aria-hidden className="@rail:hidden" />
|
||||
<Row className="hidden @rail:flex shrink-0 gap-1! flex-nowrap items-center">
|
||||
{hidden.slice(0, AppData.RailSeeAllStones).map((bot) => (
|
||||
<RailSeeAllStone key={bot.id} bot={bot} />
|
||||
))}
|
||||
</Row>
|
||||
<Span className="hidden lg:inline text-[13px] font-bold leading-4">{label}</Span>
|
||||
<Span className="hidden @rail:inline text-[13px] font-bold leading-4">{label}</Span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export default function RailSettingsAside({ pathname }: Props) {
|
||||
return (
|
||||
<>
|
||||
<RailBackLink title={back.title} href={back.href} />
|
||||
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
<Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
<RailSettingsRecordRow
|
||||
avatar={avatar}
|
||||
title={title}
|
||||
|
||||
@@ -18,16 +18,16 @@ export default function RailSettingsLink({ title, url, Icon, selected }: Props)
|
||||
title={title}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
className={twMerge(
|
||||
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink",
|
||||
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal text-ink",
|
||||
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
|
||||
selected && "bg-ink/5 hover:bg-ink/5",
|
||||
)}
|
||||
>
|
||||
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0">
|
||||
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto @rail:mx-0">
|
||||
<Icon size={18} aria-hidden />
|
||||
</Span>
|
||||
<Span className="hidden lg:inline text-sm font-bold leading-5 text-ink">
|
||||
<Span className="hidden @rail:inline text-sm font-bold leading-5 text-ink">
|
||||
{title}
|
||||
</Span>
|
||||
</Link>
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function RailSettingsList({ pathname }: Props) {
|
||||
return (
|
||||
<>
|
||||
<RailBackLink />
|
||||
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
<Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
{SettingsPages.filter((page) =>
|
||||
page.can_view
|
||||
? page.can_view({ user_types: pageProps?.user_types })
|
||||
|
||||
@@ -21,10 +21,10 @@ export default function RailSettingsRecordRow({
|
||||
return (
|
||||
<Row
|
||||
aria-current="page"
|
||||
className="w-full shrink-0 min-h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-3 bg-ink/5"
|
||||
className="w-full shrink-0 min-h-12 gap-3! flex-nowrap items-center rounded-default px-1 @rail:px-3 bg-ink/5"
|
||||
>
|
||||
{avatar}
|
||||
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
|
||||
<Stack className="hidden @rail:flex grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{title}
|
||||
</Span>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
Icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A popover item that acts rather than navigates, e.g. Install app.
|
||||
*/
|
||||
export default function RailUserMenuAction({
|
||||
title,
|
||||
Icon,
|
||||
onClick,
|
||||
disabled,
|
||||
}: Props) {
|
||||
return (
|
||||
<Row
|
||||
role="menuitem"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-disabled={disabled}
|
||||
title={title}
|
||||
onClick={() => {
|
||||
if (!disabled) onClick();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (disabled) return;
|
||||
if (event.key == "Enter" || event.key == " ") {
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className={`w-full h-9 gap-2.5! flex-nowrap items-center px-4 ${
|
||||
disabled
|
||||
? "text-ink-disabled cursor-not-allowed"
|
||||
: "text-ink cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ink"
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} aria-hidden />
|
||||
<Span className="text-sm leading-5">{title}</Span>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useContext } from "react";
|
||||
"use client";
|
||||
|
||||
import { useContext, useState } from "react";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import { Download } from "lucide-react";
|
||||
import { RailUserMenuItems } from "../(dict)/rail-user-menu-dict";
|
||||
import RailUserMenuEntry from "./rail-user-menu-entry";
|
||||
import RailUserMenuAction from "./rail-user-menu-action";
|
||||
import InstallAppModal from "./install-app-modal";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import useInstallPrompt from "@/src/hooks/use-install-prompt";
|
||||
|
||||
/**
|
||||
* 220px popover: the only element in the shell with elevation. Super
|
||||
* admin items show to super admins only.
|
||||
* admin items show to super admins only. Install app shows while the
|
||||
* browser can install it; Safari gets the Share menu steps instead.
|
||||
*/
|
||||
export default function RailUserMenu() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
@@ -17,6 +24,12 @@ export default function RailUserMenu() {
|
||||
const items = shown.filter((item) => !item.destructive);
|
||||
const destructive = shown.filter((item) => item.destructive);
|
||||
|
||||
const { can_install, needs_ios_steps, installing, install } =
|
||||
useInstallPrompt();
|
||||
const [ios_modal_open, setIosModalOpen] = useState(false);
|
||||
|
||||
const show_install = can_install || needs_ios_steps;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
role="menu"
|
||||
@@ -25,10 +38,30 @@ export default function RailUserMenu() {
|
||||
{items.map((item) => (
|
||||
<RailUserMenuEntry key={item.title} {...item} />
|
||||
))}
|
||||
{show_install ? (
|
||||
<RailUserMenuAction
|
||||
title={needs_ios_steps ? "How to install" : "Install app"}
|
||||
Icon={Download}
|
||||
disabled={installing}
|
||||
onClick={() => {
|
||||
if (needs_ios_steps) {
|
||||
setIosModalOpen(true);
|
||||
return;
|
||||
}
|
||||
install();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Divider className="my-1 mx-3 w-auto border-hairline dark:border-hairline" />
|
||||
{destructive.map((item) => (
|
||||
<RailUserMenuEntry key={item.title} {...item} />
|
||||
))}
|
||||
{needs_ios_steps ? (
|
||||
<InstallAppModal
|
||||
open={ios_modal_open}
|
||||
setOpen={setIosModalOpen}
|
||||
/>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function RailUserRow() {
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<Stack className="h-[88px] shrink-0 gap-0! items-stretch justify-center px-2 lg:px-3 mx-0 lg:mx-3 border-0 border-t border-solid border-hairline">
|
||||
<Stack className="h-[88px] shrink-0 gap-0! items-stretch justify-center px-2 @rail:px-3 mx-0 @rail:mx-3 border-0 border-t border-solid border-hairline">
|
||||
<Dropdown
|
||||
position="top-left"
|
||||
className="w-full"
|
||||
@@ -34,7 +34,7 @@ export default function RailUserRow() {
|
||||
tabIndex={0}
|
||||
aria-haspopup="menu"
|
||||
aria-label={`Account menu for ${name}`}
|
||||
className="w-full h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-0 cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
className="w-full h-12 gap-3! flex-nowrap items-center rounded-default px-1 @rail:px-0 cursor-pointer hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
<UserAvatar
|
||||
src={user?.image_thumbnail || user?.image}
|
||||
@@ -42,7 +42,7 @@ export default function RailUserRow() {
|
||||
initials={initials}
|
||||
size="md"
|
||||
/>
|
||||
<Stack className="hidden lg:flex grow min-w-0 gap-1">
|
||||
<Stack className="hidden @rail:flex grow min-w-0 gap-1">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{name}
|
||||
</Span>
|
||||
@@ -63,7 +63,7 @@ export default function RailUserRow() {
|
||||
</Stack>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className="hidden lg:block shrink-0 text-ink"
|
||||
className="hidden @rail:block shrink-0 text-ink"
|
||||
aria-hidden
|
||||
/>
|
||||
</Row>
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function RailWorkspaceList({ workspace, bots, selected_id, on_wor
|
||||
title={on_workspace_page ? "All bots" : `Back to ${workspace.name}`}
|
||||
href={on_workspace_page ? undefined : base}
|
||||
/>
|
||||
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
<Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||
<WorkspaceRow workspace={{ ...workspace, unread: 0 }} selected={on_workspace_page} />
|
||||
<RailBotsSection
|
||||
action={<RailSectionAddButton title="Add a bot to this workspace" onClick={() => openForm({ kind: "bot" })} />}
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function RailWorkspacesSection({ workspaces, selected_id }: Props
|
||||
<WorkspaceRow key={workspace.id} workspace={workspace} selected={workspace.id == selected_id} />
|
||||
))}
|
||||
{workspaces.length ? null : (
|
||||
<Span className="hidden lg:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
|
||||
<Span className="hidden @rail:block px-3 py-2 text-[13px] leading-[18px] text-ink-secondary">
|
||||
No workspaces yet. A workspace keeps a project's context, bots and computers together.
|
||||
</Span>
|
||||
)}
|
||||
|
||||
@@ -21,14 +21,14 @@ export default function WorkspaceRow({ workspace, selected }: Props) {
|
||||
aria-current={selected ? "page" : undefined}
|
||||
title={workspace.name}
|
||||
className={twMerge(
|
||||
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink",
|
||||
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 @rail:px-3 border-0 leading-normal text-ink",
|
||||
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
|
||||
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
|
||||
selected && "bg-ink/5 hover:bg-ink/5",
|
||||
)}
|
||||
>
|
||||
<WorkspaceAvatar image_media_id={workspace.image_media_id} className="mx-auto lg:mx-0" />
|
||||
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
|
||||
<WorkspaceAvatar image_media_id={workspace.image_media_id} className="mx-auto @rail:mx-0" />
|
||||
<Stack className="hidden @rail:flex grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{workspace.name}</Span>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{workspace.unread ? "New messages" : workspace.description || ""}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import useLiveBots from "@/src/hooks/use-live-bots";
|
||||
import useLiveWorkspaces from "../(hooks)/use-live-workspaces";
|
||||
import { RailPages } from "@/src/dict/rail-pages-dict";
|
||||
import { RailDrawerContext } from "../(data)/rail-drawer-context";
|
||||
import RailHeader from "../(partials)/rail-header";
|
||||
import RailPagesList from "../(partials)/rail-pages-list";
|
||||
import RailCrewList from "../(partials)/rail-crew-list";
|
||||
@@ -16,10 +19,14 @@ import RailWorkspaceList from "../(partials)/rail-workspace-list";
|
||||
* the latest bots, the workspaces). Inside a workspace it lists that
|
||||
* workspace's crew instead; on `/admin/settings/*` a back link and the
|
||||
* settings sub pages, and on routines, activity and help a back link and
|
||||
* those pages (`RailPages`). Collapses to a 64px icon rail below 1024px.
|
||||
* those pages (`RailPages`). Below `md` it is a slide-over drawer from the
|
||||
* pane header's menu button; from `md` it is a 64px icon rail, and from
|
||||
* `lg` the full 260px rail. Its own width, not the viewport's, decides
|
||||
* whether it shows names (a container query), so the drawer opens full.
|
||||
*/
|
||||
export default function BotsRail() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { open, setOpen } = useContext(RailDrawerContext);
|
||||
|
||||
const pathname = pageProps?.url?.pathname;
|
||||
const is_settings_page = Boolean(pathname?.startsWith("/admin/settings"));
|
||||
@@ -41,32 +48,43 @@ export default function BotsRail() {
|
||||
const live_workspace = workspaces.find((w) => w.id == workspace?.id);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
className="w-16 lg:w-[260px] shrink-0 h-full gap-0! items-stretch border-0 border-r border-solid border-hairline"
|
||||
aria-label={is_settings_page ? "Settings" : in_workspace ? workspace?.name || "Workspace" : "Bots"}
|
||||
>
|
||||
<RailHeader show_add={!is_settings_page && !is_rail_page} />
|
||||
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1 pb-3">
|
||||
{is_settings_page ? (
|
||||
<RailSettingsAside pathname={pathname} />
|
||||
) : is_rail_page ? (
|
||||
<RailPagesList pathname={pathname} />
|
||||
) : in_workspace && live_workspace ? (
|
||||
<RailWorkspaceList
|
||||
workspace={live_workspace}
|
||||
bots={bots}
|
||||
selected_id={pageProps?.bot?.id || undefined}
|
||||
on_workspace_page={!pageProps?.bot?.id}
|
||||
/>
|
||||
) : (
|
||||
<RailCrewList
|
||||
bots={bots}
|
||||
workspaces={workspaces}
|
||||
selected_id={pageProps?.bot?.id || undefined}
|
||||
/>
|
||||
)}
|
||||
<>
|
||||
{open ? (
|
||||
<Stack
|
||||
aria-hidden
|
||||
onClick={() => setOpen(false)}
|
||||
className="fixed inset-0 z-30 bg-ink/40 md:hidden"
|
||||
/>
|
||||
) : null}
|
||||
<Stack
|
||||
className={`@container fixed inset-y-0 left-0 z-40 w-[280px] max-w-[85vw] shrink-0 h-full gap-0! items-stretch bg-paper border-0 border-r border-solid border-hairline shadow-popover transition-transform duration-[var(--m-base)] pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] md:static md:z-auto md:w-16 md:max-w-none md:shadow-none md:pt-0 md:pb-0 lg:w-[260px] ${
|
||||
open ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||
}`}
|
||||
aria-label={is_settings_page ? "Settings" : in_workspace ? workspace?.name || "Workspace" : "Bots"}
|
||||
>
|
||||
<RailHeader show_add={!is_settings_page && !is_rail_page} />
|
||||
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 @rail:px-3 pt-1 pb-3">
|
||||
{is_settings_page ? (
|
||||
<RailSettingsAside pathname={pathname} />
|
||||
) : is_rail_page ? (
|
||||
<RailPagesList pathname={pathname} />
|
||||
) : in_workspace && live_workspace ? (
|
||||
<RailWorkspaceList
|
||||
workspace={live_workspace}
|
||||
bots={bots}
|
||||
selected_id={pageProps?.bot?.id || undefined}
|
||||
on_workspace_page={!pageProps?.bot?.id}
|
||||
/>
|
||||
) : (
|
||||
<RailCrewList
|
||||
bots={bots}
|
||||
workspaces={workspaces}
|
||||
selected_id={pageProps?.bot?.id || undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<RailUserRow />
|
||||
</Stack>
|
||||
<RailUserRow />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import useWebSocket from "@moduletrace/twui/hooks/useWebSocket";
|
||||
|
||||
/**
|
||||
* The shell's one socket. Remounted with a new key when the connection
|
||||
* may have been lost, since twui's hook stops retrying after three tries.
|
||||
*/
|
||||
export default function LiveSocket() {
|
||||
useWebSocket({ url: "/ws" });
|
||||
|
||||
return null;
|
||||
}
|
||||
+54
-13
@@ -1,33 +1,74 @@
|
||||
import React from "react";
|
||||
"use client";
|
||||
|
||||
import React, { useContext, useEffect, useMemo, useState } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import useWebSocket from "@moduletrace/twui/hooks/useWebSocket";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import useConnectionEpoch from "@/src/hooks/use-connection-epoch";
|
||||
import useOnlineStatus from "@/src/hooks/use-online-status";
|
||||
import { RailDrawerContext } from "./(data)/rail-drawer-context";
|
||||
import BotsRail from "./(sections)/bots-rail";
|
||||
import LiveSocket from "./(sections)/live-socket";
|
||||
import AddFlow from "./(partials)/add-flow";
|
||||
import OfflineBanner from "./(partials)/offline-banner";
|
||||
|
||||
/**
|
||||
* Full-bleed shell for every `/admin/*` page: the rail of bot conversations
|
||||
* and workspaces, then the page (a conversation, a workspace, or
|
||||
* settings), inside the Add flow both can open. No cards, no shadows;
|
||||
* regions are separated by 1px hairlines.
|
||||
* regions are separated by 1px hairlines. Below `md` the rail slides over
|
||||
* the page instead of taking width. Height is `dvh`, so the on-screen
|
||||
* keyboard doesn't push the composer out of view.
|
||||
*/
|
||||
export default function BotsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const pathname = pageProps?.url?.pathname;
|
||||
|
||||
const [drawer_open, setDrawerOpen] = useState(false);
|
||||
const epoch = useConnectionEpoch();
|
||||
const online = useOnlineStatus();
|
||||
|
||||
/**
|
||||
* One socket for live bot updates (see `useServerEvent`).
|
||||
* Opening a conversation closes the drawer on a phone.
|
||||
*/
|
||||
useWebSocket({ url: "/ws" });
|
||||
useEffect(() => {
|
||||
setDrawerOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawer_open) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key == "Escape") setDrawerOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [drawer_open]);
|
||||
|
||||
const drawer = useMemo(
|
||||
() => ({ open: drawer_open, setOpen: setDrawerOpen }),
|
||||
[drawer_open],
|
||||
);
|
||||
|
||||
return (
|
||||
<AddFlow>
|
||||
<Row className="w-full h-screen gap-0! flex-nowrap items-stretch overflow-hidden bg-paper text-ink">
|
||||
<BotsRail />
|
||||
<Row className="grow min-w-[480px] h-full gap-0! flex-nowrap items-stretch">
|
||||
{children}
|
||||
</Row>
|
||||
</Row>
|
||||
</AddFlow>
|
||||
<RailDrawerContext.Provider value={drawer}>
|
||||
<AddFlow>
|
||||
<Stack className="w-full h-dvh gap-0! items-stretch overflow-hidden bg-paper text-ink pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]">
|
||||
{online ? null : <OfflineBanner />}
|
||||
<Row className="grow min-h-0 w-full gap-0! flex-nowrap items-stretch overflow-hidden">
|
||||
<BotsRail />
|
||||
<Row className="grow min-w-0 h-full gap-0! flex-nowrap items-stretch">
|
||||
{children}
|
||||
</Row>
|
||||
</Row>
|
||||
</Stack>
|
||||
<LiveSocket key={epoch} />
|
||||
</AddFlow>
|
||||
</RailDrawerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user