store user login
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user