store user login
This commit is contained in:
@@ -25,6 +25,15 @@ export interface KpiCardProps {
|
||||
note?: string;
|
||||
tone?: KpiTone;
|
||||
icon?: ReactNode;
|
||||
/**
|
||||
* Accepted and ignored.
|
||||
*
|
||||
* The underline bar this used to drive was removed from the tile. Six call
|
||||
* sites still pass it, so the prop stays declared to keep them compiling —
|
||||
* it is not read. Either drop it from the callers or restore the bar; right
|
||||
* now it is neither, and this comment is here so that is visible.
|
||||
*/
|
||||
fill?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,17 +48,47 @@ export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardP
|
||||
|
||||
return (
|
||||
<Card padding={0} elevation="low">
|
||||
<VStack gap={1} padding={2} style={{ position: 'relative', paddingBottom: 12, minHeight: 108 }}>
|
||||
<HStack justify="between" align="start" gap={1}>
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em', lineHeight: 1.35 }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{icon ? <span style={{ color, display: 'flex' }}>{icon}</span> : null}
|
||||
<VStack gap={1} padding={2} style={{ minHeight: 108 }}>
|
||||
{/* Icon first, then the label; the pill sits opposite it. Both are
|
||||
fixed to this row so tiles line up whether or not they carry one. */}
|
||||
{/* A fixed height, so a label that wraps to two lines (a long note
|
||||
squeezes it) does not push that tile's value below its neighbours'. */}
|
||||
<HStack justify="between" align="center" gap={1} style={{ minHeight: 32 }}>
|
||||
<HStack gap={1} align="center" style={{ minWidth: 0 }}>
|
||||
{icon ? (
|
||||
<span style={{ color, display: 'flex', flex: 'none' }} aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<Text
|
||||
type="label"
|
||||
size="xsm"
|
||||
color="secondary"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '0.09em', lineHeight: 1.35 }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{note ? (
|
||||
<span
|
||||
style={{
|
||||
flex: 'none',
|
||||
maxWidth: '58%',
|
||||
background: 'var(--color-slate-100)',
|
||||
color: 'var(--color-slate-500)',
|
||||
borderRadius: 12,
|
||||
padding: '2px 8px',
|
||||
fontSize: 10.5,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{note}
|
||||
</span>
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
<HStack justify="center" align="center" style={{ flex: 1 }}>
|
||||
@@ -61,24 +100,6 @@ export function KpiCard({ label, value, note, tone = 'neutral', icon }: KpiCardP
|
||||
{value}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{note ? (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
left: 16,
|
||||
background: 'var(--color-slate-100)',
|
||||
color: 'var(--color-slate-500)',
|
||||
borderRadius: 12,
|
||||
padding: '2px 6px',
|
||||
fontSize: 10.5,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{note}
|
||||
</span>
|
||||
) : null}
|
||||
</VStack>
|
||||
|
||||
</Card>
|
||||
|
||||
@@ -6,13 +6,16 @@ import { HStack } from '@astryxdesign/core/HStack';
|
||||
export interface SectionHeaderProps {
|
||||
title: string;
|
||||
/**
|
||||
* Sits inline beside the title, not beneath it.
|
||||
* Sits inline beside the title, not beneath it — and only when it carries a
|
||||
* fact, such as a count.
|
||||
*
|
||||
* Required rather than optional on purpose: a bare heading tells the reader
|
||||
* what a block is called but not what it is showing them, and every section
|
||||
* in this console has something worth saying there.
|
||||
* It used to be required, on the theory that every section should say what it
|
||||
* is showing. In practice that produced a line of editorial beside every
|
||||
* heading — "how today is going", "a queue nobody can see from the shop
|
||||
* floor" — which reads as noise once you have seen the page twice. The
|
||||
* heading and the data under it say enough.
|
||||
*/
|
||||
note: string;
|
||||
note?: string;
|
||||
/** A control or a "View all" link, right-aligned. */
|
||||
action?: ReactNode;
|
||||
}
|
||||
@@ -24,9 +27,11 @@ export function SectionHeader({ title, note, action }: SectionHeaderProps) {
|
||||
<Heading level={2}>
|
||||
{title}
|
||||
</Heading>
|
||||
<Text type="body" color="secondary">
|
||||
{note}
|
||||
</Text>
|
||||
{note ? (
|
||||
<Text type="body" color="secondary">
|
||||
{note}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
{action}
|
||||
</HStack>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Bell, ChevronDown, ChevronLeft, LogOut, Menu, Search, Sparkles, X } from 'lucide-react';
|
||||
import { Bell, ChevronDown, ChevronLeft, LogOut, Menu, Search, X } from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { ROLE_LABEL } from '@/auth/roles';
|
||||
@@ -49,6 +49,15 @@ export interface AppShellProps {
|
||||
* here for.
|
||||
*/
|
||||
manageItems?: readonly MenuEntry[];
|
||||
/**
|
||||
* Workspace-specific controls in the header, left of the notification bell.
|
||||
*
|
||||
* For things a shop reaches from anywhere and that open in place rather than
|
||||
* navigating — the store's QR code is the first. A page for it would have
|
||||
* been a fifth destination for something that is looked at, printed once, and
|
||||
* closed.
|
||||
*/
|
||||
headerActions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +76,14 @@ export interface AppShellProps {
|
||||
* control — so those are props. Copying four hundred lines of chrome per role
|
||||
* is how two headers drift apart and stop looking like one product.
|
||||
*/
|
||||
export function AppShell({ nav, home, navLabel, scopeControl, manageItems }: AppShellProps) {
|
||||
export function AppShell({
|
||||
nav,
|
||||
home,
|
||||
navLabel,
|
||||
scopeControl,
|
||||
manageItems,
|
||||
headerActions,
|
||||
}: AppShellProps) {
|
||||
const { user, signOut } = useAuth();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
@@ -291,6 +307,8 @@ export function AppShell({ nav, home, navLabel, scopeControl, manageItems }: App
|
||||
</kbd>
|
||||
</form>
|
||||
|
||||
{headerActions}
|
||||
|
||||
<IconButton label="Notifications, 2 unread" hasUnread>
|
||||
<Bell size={16} />
|
||||
</IconButton>
|
||||
@@ -733,7 +751,7 @@ function initials(name: string): string {
|
||||
* `label` is required, not optional — an icon-only control with no accessible
|
||||
* name is a bug, so the API makes it impossible to omit.
|
||||
*/
|
||||
function IconButton({
|
||||
export function IconButton({
|
||||
label,
|
||||
children,
|
||||
onClick,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
|
||||
import {
|
||||
DEFAULT_WIDTH,
|
||||
expandedWidth,
|
||||
MAX_WIDTH,
|
||||
MIN_WIDTH,
|
||||
useAssistantWidth,
|
||||
} from './assistantWidth';
|
||||
|
||||
/** Per-route context, so the panel knows which page it is sitting beside. */
|
||||
const CONTEXT: Record<
|
||||
@@ -135,19 +142,57 @@ export function AssistantPanel({
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { width, setWidth, reset } = useAssistantWidth();
|
||||
const [isDragging, setDragging] = useState(false);
|
||||
|
||||
const key = Object.keys(CONTEXT).find((entry) => pathname.startsWith(entry));
|
||||
const context = (key ? CONTEXT[key] : undefined) ?? FALLBACK;
|
||||
|
||||
const expandedStyle: React.CSSProperties = isExpanded
|
||||
? {
|
||||
width: '50vw',
|
||||
maxWidth: 'none',
|
||||
}
|
||||
: {};
|
||||
/**
|
||||
* Three widths, in priority order: stacked (the phone layout owns it),
|
||||
* expanded (the one-click half-screen), then whatever the operator dragged
|
||||
* it to. The inline width beats the media queries in `index.css`, which is
|
||||
* the point — those are the default, not the rule.
|
||||
*
|
||||
* Height is deliberately NOT set here. It was, briefly — measured off the
|
||||
* page so a short page got a short panel — but that put a strip of page
|
||||
* background under the panel on every screen that was not full. The column
|
||||
* is full height, always, and `.assistant` owns that.
|
||||
*/
|
||||
const sizeStyle: React.CSSProperties = isStacked
|
||||
? {}
|
||||
: isExpanded
|
||||
? { width: expandedWidth(window.innerWidth), maxWidth: 'none' }
|
||||
: { width, maxWidth: 'none' };
|
||||
|
||||
return (
|
||||
<aside aria-label="Nearle Buddy" className="assistant" style={expandedStyle}>
|
||||
/* No `position` here. `.assistant` is `position: sticky` from md up, and an
|
||||
inline `relative` silently beat it — which is what made the panel scroll
|
||||
away with the page. Sticky is itself a containing block, so the resize
|
||||
handle's `absolute` still anchors to it. */
|
||||
<aside aria-label="Nearle Buddy" className="assistant" style={sizeStyle}>
|
||||
{isStacked ? null : (
|
||||
<ResizeHandle
|
||||
width={width}
|
||||
isDragging={isDragging}
|
||||
onDragStart={() => {
|
||||
setDragging(true);
|
||||
// Dragging is a deliberate size, so it leaves the half-screen
|
||||
// preset rather than fighting it.
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
onDrag={(next) => setWidth(next, { persist: false })}
|
||||
onDragEnd={(next) => {
|
||||
setDragging(false);
|
||||
setWidth(next);
|
||||
}}
|
||||
onNudge={(delta) => {
|
||||
setIsExpanded(false);
|
||||
setWidth(width + delta);
|
||||
}}
|
||||
onReset={reset}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
@@ -376,6 +421,126 @@ function Chip({ label, onSelect }: { label: string; onSelect: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The drag handle, on the panel's left edge.
|
||||
*
|
||||
* A real `separator` rather than a decorative strip: it takes focus, answers
|
||||
* the arrow keys in 16px steps, Home and End go to the two limits, and Enter or
|
||||
* a double-click restores the default. Resizing by mouse only would put the one
|
||||
* control that fixes a cramped page out of reach of anyone not using one.
|
||||
*
|
||||
* The hit area is 10px wide while the line it draws is 2px — a 2px target is a
|
||||
* target you miss, and the padding costs nothing because it sits in the gap
|
||||
* between the page and the panel.
|
||||
*/
|
||||
function ResizeHandle({
|
||||
width,
|
||||
isDragging,
|
||||
onDragStart,
|
||||
onDrag,
|
||||
onDragEnd,
|
||||
onNudge,
|
||||
onReset,
|
||||
}: {
|
||||
width: number;
|
||||
isDragging: boolean;
|
||||
onDragStart: () => void;
|
||||
onDrag: (next: number) => void;
|
||||
onDragEnd: (next: number) => void;
|
||||
onNudge: (delta: number) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const [isHovered, setHovered] = useState(false);
|
||||
const latest = useRef(width);
|
||||
latest.current = width;
|
||||
|
||||
function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
onDragStart();
|
||||
// The width is measured from the right edge of the window, so the panel
|
||||
// tracks the pointer exactly however the page is scrolled.
|
||||
const move = (moveEvent: PointerEvent) => {
|
||||
const next = window.innerWidth - moveEvent.clientX;
|
||||
latest.current = next;
|
||||
onDrag(next);
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
document.body.style.removeProperty('user-select');
|
||||
document.body.style.removeProperty('cursor');
|
||||
onDragEnd(latest.current);
|
||||
};
|
||||
// Without these two the drag selects the text it passes over and the
|
||||
// cursor flickers back to a caret every time it leaves the handle.
|
||||
document.body.style.setProperty('user-select', 'none');
|
||||
document.body.style.setProperty('cursor', 'col-resize');
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
}
|
||||
|
||||
const isActive = isDragging || isHovered;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize Nearle Buddy"
|
||||
aria-valuenow={Math.round(width)}
|
||||
aria-valuemin={MIN_WIDTH}
|
||||
aria-valuemax={MAX_WIDTH}
|
||||
tabIndex={0}
|
||||
onPointerDown={onPointerDown}
|
||||
onDoubleClick={onReset}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
onNudge(16);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
onNudge(-16);
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
onNudge(MAX_WIDTH);
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
onNudge(-MAX_WIDTH);
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onReset();
|
||||
}
|
||||
}}
|
||||
title={`${Math.round(width)}px — drag to resize, double-click for ${DEFAULT_WIDTH}px`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: -5,
|
||||
width: 10,
|
||||
height: '100%',
|
||||
zIndex: 5,
|
||||
cursor: 'col-resize',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 2,
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
background: isActive ? 'var(--color-brand)' : 'transparent',
|
||||
transition: isDragging ? 'none' : 'background .15s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelIcon({
|
||||
label,
|
||||
children,
|
||||
|
||||
94
src/components/shell/assistantWidth.ts
Normal file
94
src/components/shell/assistantWidth.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* How wide Nearle Buddy is, remembered.
|
||||
*
|
||||
* The panel is a layout column, not an overlay — the page reflows beside it —
|
||||
* so its width is a trade the operator makes against their own screen, and the
|
||||
* right answer differs per person and per monitor. Someone on a 1280 laptop
|
||||
* reading a wide table wants it narrow; someone on a 27" wants a conversation
|
||||
* they can read.
|
||||
*
|
||||
* Kept in `localStorage` rather than in the session or the URL: it is a comfort
|
||||
* setting for this browser on this machine, it should survive a reload and a
|
||||
* sign-out, and it should NOT travel in a link — a pasted link that also
|
||||
* resized the recipient's panel would be a surprise.
|
||||
*/
|
||||
const KEY = 'nearle.buddy.width.v1';
|
||||
|
||||
/** The CSS default, matched to `.assistant` in `index.css`. */
|
||||
export const DEFAULT_WIDTH = 380;
|
||||
|
||||
/**
|
||||
* The floor and ceiling, taken from KROW rather than invented.
|
||||
*
|
||||
* Its assistant column is 340-520 by drag with a 380 default, and the expanded
|
||||
* state goes to 620 capped at 42% of the viewport. Matching those numbers is
|
||||
* the point of having a design system: below 340 the prompt chips stop fitting
|
||||
* on one line, and past 520 the page the panel is meant to be *about* loses its
|
||||
* measure.
|
||||
*/
|
||||
export const MIN_WIDTH = 340;
|
||||
export const MAX_WIDTH = 520;
|
||||
|
||||
/** The one-click expanded width, and the share of the viewport it may take. */
|
||||
export const EXPANDED_WIDTH = 620;
|
||||
export const MAX_VIEWPORT_SHARE = 0.42;
|
||||
|
||||
export function clampWidth(value: number, viewport: number): number {
|
||||
const ceiling = Math.min(
|
||||
MAX_WIDTH,
|
||||
Math.max(MIN_WIDTH, Math.round(viewport * MAX_VIEWPORT_SHARE)),
|
||||
);
|
||||
if (!Number.isFinite(value)) return DEFAULT_WIDTH;
|
||||
return Math.min(ceiling, Math.max(MIN_WIDTH, Math.round(value)));
|
||||
}
|
||||
|
||||
/** The expanded width, under the same viewport share. */
|
||||
export function expandedWidth(viewport: number): number {
|
||||
return Math.min(EXPANDED_WIDTH, Math.round(viewport * MAX_VIEWPORT_SHARE));
|
||||
}
|
||||
|
||||
function read(): number {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(KEY);
|
||||
if (raw === null) return DEFAULT_WIDTH;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : DEFAULT_WIDTH;
|
||||
} catch {
|
||||
// Private windows and blocked site data throw on access, not on read.
|
||||
return DEFAULT_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
export function useAssistantWidth() {
|
||||
const [width, setWidthState] = useState<number>(() =>
|
||||
typeof window === 'undefined' ? DEFAULT_WIDTH : clampWidth(read(), window.innerWidth),
|
||||
);
|
||||
|
||||
const setWidth = useCallback((next: number, options?: { persist?: boolean }) => {
|
||||
const clamped = clampWidth(next, window.innerWidth);
|
||||
setWidthState(clamped);
|
||||
if (options?.persist === false) return;
|
||||
try {
|
||||
window.localStorage.setItem(KEY, String(clamped));
|
||||
} catch {
|
||||
// Not being able to remember it is not a reason to refuse to resize it.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => setWidth(DEFAULT_WIDTH), [setWidth]);
|
||||
|
||||
// A window narrowed after the width was set would otherwise leave the panel
|
||||
// eating the page. Re-clamped on resize, but never written back — the stored
|
||||
// preference is what they chose on a big screen and should return with it.
|
||||
useEffect(() => {
|
||||
function onResize() {
|
||||
setWidthState((current) => clampWidth(current, window.innerWidth));
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
return { width, setWidth, reset };
|
||||
}
|
||||
Reference in New Issue
Block a user