store user login

This commit is contained in:
2026-08-25 18:00:16 +05:30
parent 1dc582ba07
commit 517a99d577
62 changed files with 8059 additions and 650 deletions

View File

@@ -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,