Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -4195,3 +4195,159 @@ tr.insights-row-clickable:hover {
grid-template-columns: 1fr;
}
}

/* Sidebar bottom: profile button, Tools button, account menu. */
.sb-bottom {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 8px 0;
margin-top: 6px;
border-top: 1px solid var(--line);
position: relative;
}
.sb-profile {
width: 36px;
height: 36px;
flex: 0 0 36px;
border: 0;
background: transparent;
border-radius: var(--r-md);
display: grid;
place-items: center;
cursor: pointer;
transition: background-color 120ms ease;
}
.sb-profile:hover {
background: var(--hover);
}
.sb-tools {
flex: 1;
height: 36px;
border: 0;
border-radius: var(--r-md);
background: var(--surface);
box-shadow: var(--raised);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font: inherit;
font-size: 13.5px;
font-weight: 700;
color: var(--ink);
cursor: pointer;
transition:
box-shadow 150ms ease-out,
transform 160ms var(--ease-out);
}
.sb-tools:hover {
box-shadow: var(--raised-hover);
}
.sb-tools:active {
transform: scale(0.97);
}
.sb-tools[data-active="true"] {
box-shadow: 0 0 0 1.5px var(--ink);
}
.sb-profile:focus-visible,
.sb-tools:focus-visible,
.sb-menu-item:focus-visible,
.sb-menu-theme button:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 1px;
}
.sb-menu {
position: absolute;
left: 0;
right: 0;
bottom: calc(100% + 6px);
min-width: 220px;
padding: 4px;
z-index: 40;
background: var(--surface);
border-radius: var(--r-lg);
box-shadow: var(--floating);
transform-origin: bottom left;
}
.sb-menu-head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
}
.sb-menu-head b {
display: block;
font-size: 13.5px;
line-height: 1.2;
}
.sb-menu-head span {
display: block;
font-size: 12px;
color: var(--ink-3);
}
.sb-menu-sep {
height: 1px;
background: var(--line);
margin: 4px 6px;
}
.sb-menu-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 34px;
border: 0;
background: transparent;
padding: 0 10px;
border-radius: var(--r-md);
font: inherit;
font-size: 13.5px;
text-align: left;
color: var(--ink);
cursor: pointer;
}
.sb-menu-item:hover {
background: var(--hover);
}
.sb-menu-item svg {
width: 1.05rem;
height: 1.05rem;
color: var(--ink-3);
}
.sb-menu-theme {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2px;
margin: 4px 6px;
padding: 2px;
border-radius: var(--r-md);
background: var(--hover);
}
.sb-menu-theme button {
height: 30px;
border: 0;
border-radius: 6px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font: inherit;
font-size: 12.5px;
font-weight: 700;
color: var(--ink-3);
cursor: pointer;
}
.sb-menu-theme button:hover {
color: var(--ink);
}
.sb-menu-theme button[data-active="true"] {
background: var(--surface);
color: var(--ink);
box-shadow: var(--raised);
}
.sb-menu-theme svg {
width: 0.9rem;
height: 0.9rem;
}
3 changes: 3 additions & 0 deletions apps/web/src/lib/icons/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ export {
MessageCircleMore as ChatCircleDots,
Mic as Microphone,
Minimize2 as ArrowsIn,
Moon,
MoonStar as MoonStars,
Palette,
Paperclip,
Pencil as PencilSimple,
Pin as PushPin,
Expand All @@ -68,6 +70,7 @@ export {
Square as Stop,
SquareArrowOutUpRight as ArrowSquareOut,
Star,
Sun,
TriangleAlert as Warning,
User,
UserPlus,
Expand Down
164 changes: 164 additions & 0 deletions apps/web/src/shell/sidebar-footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Profile menu (settings, theme, sign out) and the Tools button.

import { Moon, Palette, Plugs, SignOut, SlidersHorizontal, Sun, type Icon } from "@/lib/icons";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";

import { IdentityAvatar } from "../chat/avatar";
import { useSessionUser, useSignOut } from "../navigation";
import { matchesRoute, SETTINGS_PATH } from "../routes";
import { isTheme, setTheme, THEMES, useTheme, type Theme } from "../theme-store";

const THEME_OPTIONS: Record<Theme, { readonly label: string; readonly icon: Icon }> = {
light: { label: "Light", icon: Sun },
dark: { label: "Dark", icon: Moon },
canvas: { label: "Canvas", icon: Palette },
};

export function SidebarFooter({
path,
onNavigate,
}: {
readonly path: string;
readonly onNavigate: (to: string) => void;
}) {
const user = useSessionUser();
const onSignOut = useSignOut();
const theme = useTheme();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const radioRefs = useRef<Partial<Record<Theme, HTMLButtonElement | null>>>({});

useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);

const close = () => {
setOpen(false);
triggerRef.current?.focus();
};

const onMenuKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.stopPropagation();
close();
}
};

const onRadioKeyDown = (event: KeyboardEvent) => {
const step =
event.key === "ArrowRight" || event.key === "ArrowDown"
? 1
: event.key === "ArrowLeft" || event.key === "ArrowUp"
? -1
: 0;
if (step === 0) return;
event.preventDefault();
const next = THEMES[(THEMES.indexOf(theme) + step + THEMES.length) % THEMES.length];
if (next === undefined) return;
setTheme(next);
radioRefs.current[next]?.focus();
};

const name = user?.name ?? "Account";
const toolsActive = matchesRoute("/tools", path);

return (
<div className="sb-bottom" ref={rootRef}>
<button
type="button"
ref={triggerRef}
className="sb-profile"
aria-haspopup="menu"
aria-expanded={open}
aria-label="Account"
onClick={() => setOpen((value) => !value)}
>
<IdentityAvatar kind="person" name={name} principalId={user?.id ?? name} />
</button>
<button
type="button"
className="sb-tools"
data-active={toolsActive ? "true" : undefined}
aria-current={toolsActive ? "page" : undefined}
onClick={() => onNavigate("/tools")}
>
<Plugs />
Tools
</button>
{open ? (
<div className="sb-menu" role="menu" aria-label="Account" onKeyDown={onMenuKeyDown}>
<div className="sb-menu-head">
<IdentityAvatar kind="person" name={name} principalId={user?.id ?? name} />
<div>
<b>{name}</b>
{user !== undefined ? <span>{user.email}</span> : null}
</div>
</div>
<div className="sb-menu-sep" />
<button
type="button"
role="menuitem"
className="sb-menu-item"
autoFocus
onClick={() => {
setOpen(false);
onNavigate(SETTINGS_PATH);
}}
>
<SlidersHorizontal />
Settings
</button>
<div className="sb-menu-theme" role="radiogroup" aria-label="Theme">
{THEMES.map((key) => {
const { label, icon: ThemeIcon } = THEME_OPTIONS[key];
const active = theme === key;
return (
<button
key={key}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
data-active={active ? "true" : undefined}
ref={(node) => {
radioRefs.current[key] = node;
}}
onKeyDown={onRadioKeyDown}
onClick={() => {
if (isTheme(key)) setTheme(key);
}}
>
<ThemeIcon />
{label}
</button>
);
})}
</div>
{onSignOut !== undefined ? (
<>
<div className="sb-menu-sep" />
<button
type="button"
role="menuitem"
className="sb-menu-item"
onClick={() => {
setOpen(false);
onSignOut();
}}
>
<SignOut />
Sign out
</button>
</>
) : null}
</div>
) : null}
</div>
);
}
Loading
Loading