[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { cn } from '../../lib/utils';
import { getFixedSizeVirtualWindow } from './virtualListMath';
const DEFAULT_OVERSCAN = 6;
export type FixedSizeVirtualListHandle = {
scrollToIndex: (index: number, align?: 'auto' | 'center') => void;
};
interface FixedSizeVirtualListProps<T> {
items: T[];
itemHeight: number;
className?: string;
contentClassName?: string;
overscan?: number;
getItemKey: (item: T, index: number) => string;
renderItem: (item: T, index: number) => React.ReactNode;
}
function FixedSizeVirtualListInner<T>(
{
items,
itemHeight,
className,
contentClassName,
overscan = DEFAULT_OVERSCAN,
getItemKey,
renderItem,
}: FixedSizeVirtualListProps<T>,
ref: React.ForwardedRef<FixedSizeVirtualListHandle>,
) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateViewportHeight = () => {
setViewportHeight(container.clientHeight);
};
updateViewportHeight();
const observer = new ResizeObserver(updateViewportHeight);
observer.observe(container);
return () => observer.disconnect();
}, []);
const {
startIndex,
endIndex,
effectiveScrollTop,
totalHeight,
} = getFixedSizeVirtualWindow({
itemCount: items.length,
itemHeight,
scrollTop,
viewportHeight,
overscan,
});
// Sync the DOM scroll position when content shrinks (filter / data change).
// Layout already uses effectiveScrollTop so the list never blanks for a frame.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (container.scrollTop !== effectiveScrollTop) {
container.scrollTop = effectiveScrollTop;
}
if (scrollTop !== effectiveScrollTop) {
setScrollTop(effectiveScrollTop);
}
}, [effectiveScrollTop, scrollTop]);
useImperativeHandle(ref, () => ({
scrollToIndex: (index: number, align = 'auto') => {
const container = containerRef.current;
if (!container || index < 0 || index >= items.length) return;
const itemTop = index * itemHeight;
const itemBottom = itemTop + itemHeight;
const viewTop = container.scrollTop;
const viewBottom = viewTop + container.clientHeight;
if (align === 'center') {
container.scrollTop = Math.max(
0,
itemTop - (container.clientHeight - itemHeight) / 2,
);
} else if (itemTop < viewTop) {
container.scrollTop = itemTop;
} else if (itemBottom > viewBottom) {
container.scrollTop = itemBottom - container.clientHeight;
}
setScrollTop(container.scrollTop);
},
}), [itemHeight, items.length]);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
setScrollTop(event.currentTarget.scrollTop);
}, []);
return (
<div
ref={containerRef}
className={cn('h-full overflow-y-auto overflow-x-hidden', className)}
onScroll={handleScroll}
>
<div
className={cn('relative w-full', contentClassName)}
style={{ height: totalHeight || undefined, minHeight: items.length === 0 ? 0 : totalHeight }}
>
{items.slice(startIndex, endIndex).map((item, offset) => {
const index = startIndex + offset;
return (
<div
key={getItemKey(item, index)}
className="absolute left-0 right-0"
style={{
top: index * itemHeight,
height: itemHeight,
}}
>
{renderItem(item, index)}
</div>
);
})}
</div>
</div>
);
}
export const FixedSizeVirtualList = forwardRef(FixedSizeVirtualListInner) as <T>(
props: FixedSizeVirtualListProps<T> & { ref?: React.ForwardedRef<FixedSizeVirtualListHandle> },
) => React.ReactElement | null;

View File

@@ -0,0 +1,178 @@
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { cn } from '../../lib/utils';
import { clampScrollTop } from './virtualListMath';
const DEFAULT_OVERSCAN = 6;
export type VariableSizeVirtualListHandle = {
scrollToIndex: (index: number, align?: 'auto' | 'center') => void;
};
interface VariableSizeVirtualListProps<T> {
items: T[];
getItemHeight: (item: T, index: number) => number;
className?: string;
contentClassName?: string;
overscan?: number;
getItemKey: (item: T, index: number) => string;
renderItem: (item: T, index: number) => React.ReactNode;
}
function VariableSizeVirtualListInner<T>(
{
items,
getItemHeight,
className,
contentClassName,
overscan = DEFAULT_OVERSCAN,
getItemKey,
renderItem,
}: VariableSizeVirtualListProps<T>,
ref: React.ForwardedRef<VariableSizeVirtualListHandle>,
) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const layout = useMemo(() => {
const offsets: number[] = [];
let total = 0;
for (let i = 0; i < items.length; i += 1) {
offsets.push(total);
total += getItemHeight(items[i], i);
}
return { offsets, totalHeight: total };
}, [getItemHeight, items]);
const effectiveScrollTop = clampScrollTop(
scrollTop,
layout.totalHeight,
viewportHeight,
);
// Sync DOM when content shrinks; render path already uses effectiveScrollTop.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (container.scrollTop !== effectiveScrollTop) {
container.scrollTop = effectiveScrollTop;
}
if (scrollTop !== effectiveScrollTop) {
setScrollTop(effectiveScrollTop);
}
}, [effectiveScrollTop, scrollTop]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateViewportHeight = () => {
setViewportHeight(container.clientHeight);
};
updateViewportHeight();
const observer = new ResizeObserver(updateViewportHeight);
observer.observe(container);
return () => observer.disconnect();
}, []);
useImperativeHandle(ref, () => ({
scrollToIndex: (index: number, align = 'auto') => {
const container = containerRef.current;
if (!container || index < 0 || index >= items.length) return;
const itemTop = layout.offsets[index] ?? 0;
const itemHeight = getItemHeight(items[index], index);
const itemBottom = itemTop + itemHeight;
const viewTop = container.scrollTop;
const viewBottom = viewTop + container.clientHeight;
if (align === 'center') {
container.scrollTop = Math.max(
0,
itemTop - (container.clientHeight - itemHeight) / 2,
);
} else if (itemTop < viewTop) {
container.scrollTop = itemTop;
} else if (itemBottom > viewBottom) {
container.scrollTop = itemBottom - container.clientHeight;
}
setScrollTop(container.scrollTop);
},
}), [getItemHeight, items, layout.offsets]);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
setScrollTop(event.currentTarget.scrollTop);
}, []);
const { startIndex, endIndex } = useMemo(() => {
if (items.length === 0) {
return { startIndex: 0, endIndex: 0 };
}
const { offsets } = layout;
// First visible row: largest index whose top <= effectiveScrollTop.
let lo = 0;
let hi = items.length - 1;
while (lo < hi) {
const mid = Math.floor((lo + hi + 1) / 2);
if ((offsets[mid] ?? 0) <= effectiveScrollTop) lo = mid;
else hi = mid - 1;
}
const start = Math.max(0, lo - overscan);
const viewBottom = effectiveScrollTop + viewportHeight;
let scan = start;
while (scan < items.length && (offsets[scan] ?? 0) < viewBottom + overscan * 40) {
scan += 1;
}
const end = Math.min(items.length, scan + overscan);
return { startIndex: start, endIndex: end };
}, [effectiveScrollTop, items.length, layout, overscan, viewportHeight]);
return (
<div
ref={containerRef}
className={cn('h-full overflow-y-auto overflow-x-hidden', className)}
onScroll={handleScroll}
>
<div
className={cn('relative w-full', contentClassName)}
style={{
height: layout.totalHeight || undefined,
minHeight: items.length === 0 ? 0 : layout.totalHeight,
}}
>
{items.slice(startIndex, endIndex).map((item, offset) => {
const index = startIndex + offset;
const top = layout.offsets[index] ?? 0;
const height = getItemHeight(item, index);
return (
<div
key={getItemKey(item, index)}
className="absolute left-0 right-0"
style={{ top, height }}
>
{renderItem(item, index)}
</div>
);
})}
</div>
</div>
);
}
export const VariableSizeVirtualList = forwardRef(VariableSizeVirtualListInner) as <T>(
props: VariableSizeVirtualListProps<T> & { ref?: React.ForwardedRef<VariableSizeVirtualListHandle> },
) => React.ReactElement | null;

View File

@@ -0,0 +1,450 @@
import { ArrowLeft, MoreVertical, X } from 'lucide-react';
import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import { localStorageAdapter } from '@/infrastructure/persistence/localStorageAdapter';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { ScrollArea } from './scroll-area';
export const DEFAULT_ASIDE_INLINE_WIDTH_PX = 380;
const MIN_ASIDE_INLINE_WIDTH_PX = 320;
const MAX_ASIDE_INLINE_WIDTH_PX = 720;
export function clampAsideInlineWidth(width: number): number {
return Math.max(MIN_ASIDE_INLINE_WIDTH_PX, Math.min(MAX_ASIDE_INLINE_WIDTH_PX, width));
}
export interface AsidePanelResizeProps {
resizable?: boolean;
persistWidthStorageKey?: string;
resizeAriaLabel?: string;
}
function parseInlineWidthPx(width: string): number {
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
if (arbitraryWidthMatch) {
const raw = arbitraryWidthMatch[1].trim();
const parsed = parseInt(raw, 10);
if (!Number.isNaN(parsed)) return clampAsideInlineWidth(parsed);
}
switch (width) {
case 'w-full':
case 'w-screen':
return DEFAULT_ASIDE_INLINE_WIDTH_PX;
default:
return DEFAULT_ASIDE_INLINE_WIDTH_PX;
}
}
function readPersistedAsideWidth(storageKey: string | undefined, fallback: number): number {
if (!storageKey) return fallback;
const stored = localStorageAdapter.readNumber(storageKey);
if (stored === null) return fallback;
return clampAsideInlineWidth(stored);
}
// Types
interface AsideContentItem {
id: string;
title: string;
subtitle?: string;
actions?: ReactNode;
content: ReactNode;
}
interface AsidePanelContextType {
push: (item: AsideContentItem) => void;
pop: () => void;
replace: (item: AsideContentItem) => void;
clear: () => void;
canGoBack: boolean;
currentItem: AsideContentItem | null;
}
const AsidePanelContext = createContext<AsidePanelContextType | null>(null);
const AsideActionMenuContext = createContext<(() => void) | null>(null);
export const useAsidePanel = () => {
const context = useContext(AsidePanelContext);
if (!context) {
throw new Error('useAsidePanel must be used within an AsidePanel');
}
return context;
};
// Props
interface AsidePanelProps extends AsidePanelResizeProps {
open: boolean;
onClose: () => void;
title?: string;
subtitle?: string;
actions?: ReactNode;
showBackButton?: boolean;
onBack?: () => void;
children: ReactNode;
className?: string;
width?: string;
layout?: AsidePanelLayout;
/**
* Optional stable identifier emitted as `data-section` on the panel
* root. Used as a targeting hook for Custom CSS (Settings → Appearance).
*/
dataSection?: string;
}
interface AsidePanelHeaderProps {
title: string;
subtitle?: string;
actions?: ReactNode;
onBack?: () => void;
onClose: () => void;
showBackButton?: boolean;
}
// Header Component
export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
title,
subtitle,
actions,
onBack,
onClose,
showBackButton = false,
}) => {
return (
<div className="px-4 py-3 flex items-center justify-between border-b border-border/60 app-no-drag shrink-0">
<div className="flex items-center gap-2 min-w-0">
{showBackButton && onBack && (
<button
onClick={onBack}
className="p-1 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
>
<ArrowLeft size={18} />
</button>
)}
<div className="min-w-0">
<h3 className="text-sm font-semibold truncate">{title}</h3>
{subtitle && (
<p className="text-xs text-muted-foreground truncate">{subtitle}</p>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{actions}
<button
onClick={onClose}
className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer"
>
<X size={18} />
</button>
</div>
</div>
);
};
// Content Component (wraps children with scroll)
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string }> = ({
children,
className,
}) => {
return (
<ScrollArea className={cn("flex-1 min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
<div className="p-4 space-y-4 min-w-0 overflow-hidden">
{children}
</div>
</ScrollArea>
);
};
// Footer Component
export const AsidePanelFooter: React.FC<{ children: ReactNode; className?: string }> = ({
children,
className,
}) => {
return (
<div className={cn("px-4 py-3 border-t border-border/60 shrink-0", className)}>
{children}
</div>
);
};
// Action Menu Component (for the ... button)
interface AsideActionMenuProps {
children: ReactNode;
}
export const AsideActionMenu: React.FC<AsideActionMenuProps> = ({ children }) => {
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer">
<MoreVertical size={18} />
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="end">
<AsideActionMenuContext.Provider value={close}>
{children}
</AsideActionMenuContext.Provider>
</PopoverContent>
</Popover>
);
};
export const invokeAsideActionMenuItemClick = (
closeMenu: (() => void) | null,
onClick?: () => void,
) => {
closeMenu?.();
onClick?.();
};
// Action Menu Item
export const AsideActionMenuItem: React.FC<{
icon?: ReactNode;
children: ReactNode;
onClick?: () => void;
variant?: 'default' | 'destructive';
}> = ({ icon, children, onClick, variant = 'default' }) => {
const closeMenu = useContext(AsideActionMenuContext);
return (
<button
onClick={() => invokeAsideActionMenuItemClick(closeMenu, onClick)}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors cursor-pointer",
variant === 'destructive'
? "text-destructive hover:bg-destructive/10"
: "hover:bg-muted"
)}
>
{icon}
{children}
</button>
);
};
// Main Panel Component with Stack Support
interface AsidePanelStackProps {
open: boolean;
onClose: () => void;
initialItem: AsideContentItem;
className?: string;
width?: string;
layout?: AsidePanelLayout;
/**
* Optional stable identifier emitted as `data-section` on the panel
* root. Used as a targeting hook for Custom CSS.
*/
dataSection?: string;
}
export type AsidePanelLayout = 'overlay' | 'inline';
const resolveInlineWidth = (width: string) => {
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
if (arbitraryWidthMatch) {
return arbitraryWidthMatch[1];
}
switch (width) {
case 'w-full':
return '100%';
case 'w-screen':
return '100vw';
default:
return '380px';
}
};
export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
open,
onClose,
initialItem,
className,
width = 'w-[380px]',
layout = 'overlay',
dataSection,
}) => {
const [stack, setStack] = useState<AsideContentItem[]>([initialItem]);
const push = useCallback((item: AsideContentItem) => {
setStack(prev => [...prev, item]);
}, []);
const pop = useCallback(() => {
setStack(prev => {
if (prev.length > 1) {
return prev.slice(0, -1);
}
return prev;
});
}, []);
const replace = useCallback((item: AsideContentItem) => {
setStack([item]);
}, []);
const clear = useCallback(() => {
setStack([initialItem]);
}, [initialItem]);
const currentItem = stack[stack.length - 1];
const canGoBack = stack.length > 1;
const inlineWidth = useMemo(() => resolveInlineWidth(width), [width]);
const inlineStyle = layout === 'inline'
? ({
width: inlineWidth,
['--aside-inline-width' as string]: inlineWidth,
} as React.CSSProperties)
: undefined;
// Reset stack when panel closes/opens
React.useEffect(() => {
if (open) {
setStack([initialItem]);
}
}, [open, initialItem]);
if (!open) return null;
return (
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
<div className={cn(
layout === 'inline'
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && width,
className
)}
style={inlineStyle}
data-section={dataSection}>
<AsidePanelHeader
title={currentItem.title}
subtitle={currentItem.subtitle}
actions={currentItem.actions}
onBack={canGoBack ? pop : undefined}
onClose={onClose}
showBackButton={canGoBack}
/>
{currentItem.content}
</div>
</AsidePanelContext.Provider>
);
};
// Simple Panel Component (no stack)
export const AsidePanel: React.FC<AsidePanelProps> = ({
open,
onClose,
title,
subtitle,
actions,
showBackButton,
onBack,
children,
className,
width = 'w-[380px]',
layout = 'overlay',
resizable = false,
persistWidthStorageKey,
resizeAriaLabel,
dataSection,
}) => {
const fallbackWidthPx = parseInlineWidthPx(width);
const [panelWidthPx, setPanelWidthPx] = useState(() =>
resizable ? readPersistedAsideWidth(persistWidthStorageKey, fallbackWidthPx) : fallbackWidthPx,
);
const [isResizing, setIsResizing] = useState(false);
const effectivePanelWidthPx = resizable ? panelWidthPx : fallbackWidthPx;
// Resizable panels always use pixel width so overlay and inline share the same drag path.
const usesPixelWidth = resizable || layout === 'inline';
const panelStyle = usesPixelWidth
? ({
width: `${effectivePanelWidthPx}px`,
['--aside-inline-width' as string]: `${effectivePanelWidthPx}px`,
} as React.CSSProperties)
: undefined;
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!resizable) return;
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = panelWidthPx;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
setIsResizing(true);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const handlePointerMove = (moveEvent: PointerEvent) => {
setPanelWidthPx(clampAsideInlineWidth(startWidth + startX - moveEvent.clientX));
};
const handlePointerUp = (upEvent: PointerEvent) => {
const nextWidth = clampAsideInlineWidth(startWidth + startX - upEvent.clientX);
setPanelWidthPx(nextWidth);
if (persistWidthStorageKey) {
localStorageAdapter.writeNumber(persistWidthStorageKey, nextWidth);
}
setIsResizing(false);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
}, [panelWidthPx, persistWidthStorageKey, resizable]);
if (!open) return null;
return (
<div className={cn(
layout === 'inline'
? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && !usesPixelWidth && width,
isResizing && 'transition-none',
className
)}
style={panelStyle}
data-section={dataSection}>
{resizable ? (
<div
role="separator"
aria-orientation="vertical"
aria-label={resizeAriaLabel}
className={cn(
'absolute left-0 top-0 z-40 h-full w-2 -translate-x-1/2 cursor-col-resize',
'after:absolute after:left-1/2 after:top-2 after:h-[calc(100%-16px)] after:w-px after:-translate-x-1/2 after:bg-border/0 after:transition-colors',
'hover:after:bg-border/70',
isResizing && 'after:bg-primary/70',
)}
onPointerDown={handleResizeStart}
/>
) : null}
{title && (
<AsidePanelHeader
title={title}
subtitle={subtitle}
actions={actions}
onClose={onClose}
showBackButton={showBackButton}
onBack={onBack}
/>
)}
{children}
</div>
);
};
export default AsidePanel;

28
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,28 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode
className?: string
variant?: "default" | "secondary" | "destructive" | "outline"
}
function Badge({ className, variant = "default", ...props }: BadgeProps) {
return (
<div
className={cn(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none",
{
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80": variant === "default",
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80": variant === "secondary",
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80": variant === "destructive",
"text-foreground": variant === "outline",
},
className
)}
{...props}
/>
)
}
export { Badge }

38
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,38 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
size?: "default" | "sm" | "lg" | "icon"
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => {
return (
<button
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
{
"bg-primary text-primary-foreground hover:bg-primary/90": variant === "default",
"bg-destructive text-destructive-foreground hover:bg-destructive/90": variant === "destructive",
"border border-input bg-background hover:bg-accent hover:text-accent-foreground": variant === "outline",
"bg-secondary text-secondary-foreground hover:bg-secondary/80": variant === "secondary",
"hover:bg-accent hover:text-accent-foreground": variant === "ghost",
"text-primary underline-offset-4 hover:underline": variant === "link",
"h-10 px-4 py-2": size === "default",
"h-9 rounded-md px-3": size === "sm",
"h-11 rounded-md px-8": size === "lg",
"h-10 w-10": size === "icon",
},
className
)}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button }

19
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
export { Card }

View File

@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible,CollapsibleContent,CollapsibleTrigger }

837
components/ui/combobox.tsx Normal file
View File

@@ -0,0 +1,837 @@
import { Check, ChevronDown, Plus, X } from "lucide-react"
import * as React from "react"
import { cn } from "../../lib/utils"
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
export interface ComboboxOption {
value: string;
label: string;
sublabel?: string;
icon?: React.ReactNode;
labelStyle?: React.CSSProperties;
}
interface ComboboxProps {
options: ComboboxOption[];
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
emptyText?: string;
allowCreate?: boolean;
onCreateNew?: (value: string) => void;
createText?: string;
icon?: React.ReactNode;
className?: string;
triggerClassName?: string;
inputStyle?: React.CSSProperties;
onInputValueChange?: (value: string) => void;
disabled?: boolean;
clearable?: boolean;
selectValueOnFocus?: boolean;
ariaLabel?: string;
}
export const comboboxWheelDeltaToPixels = (deltaY: number, deltaMode: number): number => {
if (deltaMode === 1) return deltaY * 16
if (deltaMode === 2) return deltaY * 280
return deltaY
}
export type ComboboxScrollableTarget = {
clientHeight: number;
scrollHeight: number;
scrollTop: number;
}
export const filterComboboxOptions = (
options: ComboboxOption[],
inputValue: string,
isSearching: boolean,
): ComboboxOption[] => {
if (!isSearching || !inputValue.trim()) return options
const lower = inputValue.trim().toLowerCase()
return options.filter(
(option) =>
option.label.toLowerCase().includes(lower) ||
option.value.toLowerCase().includes(lower) ||
option.sublabel?.toLowerCase().includes(lower)
)
}
export const applyComboboxWheelScroll = (
target: ComboboxScrollableTarget,
deltaY: number,
deltaMode: number,
): boolean => {
if (target.scrollHeight <= target.clientHeight) return false
target.scrollTop += comboboxWheelDeltaToPixels(deltaY, deltaMode)
return true
}
export const getNextComboboxActiveIndex = (
currentIndex: number,
optionCount: number,
direction: 1 | -1,
): number => {
if (optionCount <= 0) return -1
if (currentIndex < 0 || currentIndex >= optionCount) {
return direction === 1 ? 0 : optionCount - 1
}
return (currentIndex + direction + optionCount) % optionCount
}
export type ComboboxFocusableInput = Pick<HTMLInputElement, "focus" | "select">;
export const focusComboboxInput = (
input: ComboboxFocusableInput | null,
selectValue: boolean,
): void => {
input?.focus()
if (selectValue) input?.select()
}
export const selectComboboxInputIfFocused = (
input: ComboboxFocusableInput | null,
activeElement: Element | null,
): void => {
if (input && input === activeElement) input.select()
}
export const canComboboxOpen = (disabled: boolean, nextOpen: boolean): boolean =>
!disabled || !nextOpen
/**
* Incremental rendering limits for large option lists (e.g. the font pickers
* can list hundreds of locally installed fonts). Rendering every option at
* once freezes the UI, so we mount an initial slice and grow the window as
* the user scrolls deeper, and slide the bounded window when arrow
* navigation jumps to an option beyond it.
*/
export const COMBOBOX_INITIAL_RENDER_LIMIT = 60
export const COMBOBOX_RENDER_LIMIT_STEP = 120
const COMBOBOX_EXPAND_SCROLL_THRESHOLD_PX = 80
export const comboboxNextRenderLimit = (
currentLimit: number,
optionCount: number,
): number | null => {
if (optionCount <= currentLimit) return null
return Math.min(currentLimit + COMBOBOX_RENDER_LIMIT_STEP, optionCount)
}
export const shouldExpandComboboxWindow = (
target: ComboboxScrollableTarget,
renderedCount: number,
optionCount: number,
): boolean => {
if (renderedCount >= optionCount) return false
const distanceFromBottom = target.scrollHeight - (target.scrollTop + target.clientHeight)
return distanceFromBottom <= COMBOBOX_EXPAND_SCROLL_THRESHOLD_PX
}
export const shouldResetComboboxWindow = (target: ComboboxScrollableTarget): boolean =>
target.scrollTop <= COMBOBOX_EXPAND_SCROLL_THRESHOLD_PX
/**
* Keyboard navigation can land beyond the mounted window (e.g. ArrowUp wraps
* straight to the last option). Instead of growing the prefix until that
* index is mounted — which mounts every row for large lists — slide the
* bounded window so the destination sits at its edge. Returns the new window
* start, or null when the active option is already mounted.
*/
export const comboboxWindowStartForActiveIndex = (
activeOptionIndex: number,
windowStart: number,
renderLimit: number,
optionCount: number,
): number | null => {
if (optionCount <= 0 || activeOptionIndex < 0) return null
if (activeOptionIndex < windowStart) return activeOptionIndex
if (activeOptionIndex >= windowStart + renderLimit) {
return Math.max(0, Math.min(activeOptionIndex - renderLimit + 1, optionCount - renderLimit))
}
return null
}
function ComboboxOptionsList({
children,
id,
listbox = false,
onScrollCapture,
scrollRef,
}: {
children: React.ReactNode;
id?: string;
listbox?: boolean;
onScrollCapture?: React.UIEventHandler<HTMLDivElement>;
scrollRef?: React.RefObject<HTMLDivElement | null>;
}) {
const handleWheelCapture = (event: React.WheelEvent<HTMLDivElement>) => {
const handled = applyComboboxWheelScroll(event.currentTarget, event.deltaY, event.deltaMode)
if (!handled) return
event.preventDefault()
event.stopPropagation()
event.nativeEvent.stopImmediatePropagation()
}
return (
<div
id={id}
ref={scrollRef}
role={listbox ? "listbox" : undefined}
className="max-h-[280px] overflow-y-auto overscroll-contain p-1"
onWheelCapture={handleWheelCapture}
onScrollCapture={onScrollCapture}
>
{children}
</div>
)
}
export function Combobox({
options,
value,
onValueChange,
placeholder = "Select...",
emptyText = "No results found",
allowCreate = false,
onCreateNew,
createText = "Create",
icon,
className,
triggerClassName,
inputStyle,
onInputValueChange,
disabled = false,
clearable = true,
selectValueOnFocus = false,
ariaLabel,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState("")
const [activeIndex, setActiveIndex] = React.useState(-1)
// Track if user is actively searching (typed something after opening)
const [isSearching, setIsSearching] = React.useState(false)
// Incremental rendering window for very large option lists
const [renderLimit, setRenderLimit] = React.useState(COMBOBOX_INITIAL_RENDER_LIMIT)
// First mounted option index, so keyboard jumps slide the window instead
// of mounting the entire prefix of a large list.
const [windowStart, setWindowStart] = React.useState(0)
const inputRef = React.useRef<HTMLInputElement>(null)
const wasOpenRef = React.useRef(false)
const activeOptionRef = React.useRef<HTMLButtonElement>(null)
const optionsScrollRef = React.useRef<HTMLDivElement>(null)
// True while scroll events on the options list are caused by this
// component scrolling the active option into view (keyboard navigation,
// mouse hover) rather than by the user scrolling manually.
const navigationalScrollRef = React.useRef(false)
const listboxId = React.useId()
// Sync input value with external value when not focused
React.useEffect(() => {
const wasOpen = wasOpenRef.current
wasOpenRef.current = open
if (!open) {
const selected = options.find((opt) => opt.value === value)
setInputValue(selected?.label || value || "")
setIsSearching(false)
if (wasOpen && selectValueOnFocus) {
// The restored label lands after the close event. Reselect it on the next
// frame so the next keystroke replaces it instead of appending to it.
requestAnimationFrame(() => {
selectComboboxInputIfFocused(
inputRef.current,
typeof document === "undefined" ? null : document.activeElement,
)
})
}
}
}, [value, options, open, selectValueOnFocus])
// Show all options when dropdown is open but user hasn't started searching
const filteredOptions = React.useMemo(() => {
return filterComboboxOptions(options, inputValue, isSearching)
}, [options, inputValue, isSearching])
// Restart the window from its initial size whenever the picker opens or
// the filtered result set changes (option set, search mode, or query), so
// a previously grown or slid window never persists across a changed list.
// The reset happens during render (React discards the render output and
// re-renders immediately after a render-phase state update), so a changed
// result set is never committed with a stale, oversized window.
const [windowKey, setWindowKey] = React.useState({ open, filteredOptions })
if (windowKey.open !== open || windowKey.filteredOptions !== filteredOptions) {
setWindowKey({ open, filteredOptions })
setRenderLimit(COMBOBOX_INITIAL_RENDER_LIMIT)
setWindowStart(0)
}
const renderedOptions = React.useMemo(() => {
return filteredOptions.slice(windowStart, windowStart + renderLimit)
}, [filteredOptions, windowStart, renderLimit])
// Resetting the render window alone is not enough: the listbox DOM node
// keeps (or clamps) its previous scrollTop, so a fresh initial slice
// would be displayed near its bottom instead of at its first match.
React.useEffect(() => {
if (optionsScrollRef.current) optionsScrollRef.current.scrollTop = 0
}, [open, filteredOptions])
const expandRenderWindow = React.useCallback(() => {
setRenderLimit((current) => {
const next = comboboxNextRenderLimit(current, options.length)
return next ?? current
})
}, [options.length])
const handleOptionsScrollCapture = React.useCallback((event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget
const scrollTarget = {
clientHeight: target.clientHeight,
scrollHeight: target.scrollHeight,
scrollTop: target.scrollTop,
}
// Scrolling back to the top restores the initial window so options
// above a slid window become reachable again. The active index must
// be cleared too, otherwise the active-option effect immediately
// slides the window back to a stale active option.
// Scrolls emitted by scrolling the active option into view are not
// manual scrolls: skipping the reset keeps ArrowUp navigation able to
// slide into the preceding window without losing the active option.
if (windowStart > 0 && shouldResetComboboxWindow(scrollTarget)) {
if (navigationalScrollRef.current) return
setActiveIndex(-1)
setWindowStart(0)
setRenderLimit(COMBOBOX_INITIAL_RENDER_LIMIT)
return
}
// Programmatic scrolls from scrolling the active option into view can
// also land near the bottom of a slid window (e.g. at the last window
// while keyboard-navigating). Those are not manual scrolls either, so
// skip expansion to keep the keyboard window at a fixed size instead
// of growing it on every ArrowUp/ArrowDown wrap boundary.
if (navigationalScrollRef.current) return
if (!shouldExpandComboboxWindow(scrollTarget, renderedOptions.length, filteredOptions.length)) {
return
}
// A slid window that already reaches the final option cannot mount
// more rows by growing renderLimit (the slice is capped by the option
// count), so growing it here without mounting anything would leave an
// inflated limit behind; a later wrap back to index 0 would then reset
// windowStart to zero and mount the entire grown prefix in one commit.
if (windowStart + renderedOptions.length >= filteredOptions.length) return
expandRenderWindow()
}, [windowStart, renderedOptions.length, filteredOptions.length, expandRenderWindow])
const showCreateOption = React.useMemo(() => {
if (!allowCreate || !inputValue.trim() || !isSearching) return false
const lower = inputValue.toLowerCase().trim()
return !options.some((opt) => opt.value.toLowerCase() === lower || opt.label.toLowerCase() === lower)
}, [allowCreate, inputValue, options, isSearching])
const selectableOptionCount = filteredOptions.length + (showCreateOption ? 1 : 0)
const hasActiveOption = activeIndex >= 0 && activeIndex < selectableOptionCount
// Keyboard navigation must never land on an option that is not mounted
// yet, otherwise its aria-activedescendant id would not exist. Slide the
// bounded window so the destination is mounted without mounting the
// entire prefix of a large list. This runs during render (React discards
// the render and re-renders immediately after a render-phase state
// update), so the commit that exposes a new active descendant already
// has that option mounted — a post-commit effect would commit an
// aria-activedescendant id that does not exist in the DOM yet.
const [activeWindowKey, setActiveWindowKey] = React.useState({
activeIndex,
showCreateOption,
windowStart,
renderLimit,
filteredCount: filteredOptions.length,
})
if (
activeIndex >= 0 &&
(activeWindowKey.activeIndex !== activeIndex ||
activeWindowKey.showCreateOption !== showCreateOption ||
activeWindowKey.windowStart !== windowStart ||
activeWindowKey.renderLimit !== renderLimit ||
activeWindowKey.filteredCount !== filteredOptions.length)
) {
setActiveWindowKey({
activeIndex,
showCreateOption,
windowStart,
renderLimit,
filteredCount: filteredOptions.length,
})
const optionIndex = activeIndex - (showCreateOption ? 1 : 0)
const nextWindowStart = comboboxWindowStartForActiveIndex(
optionIndex,
windowStart,
renderLimit,
filteredOptions.length,
)
if (nextWindowStart !== null) setWindowStart(nextWindowStart)
}
React.useEffect(() => {
// Scrolling the active option into view emits scroll events even when
// keyboard navigation slides the window toward the top of the list.
// Mark them as navigational so the manual-scroll reset below does not
// mistake them for the user scrolling back to the top, which would
// clear the active option and make preceding windows unreachable by
// ArrowUp. The flag is cleared on the next frame, once the scroll
// events for this update have been dispatched.
navigationalScrollRef.current = true
activeOptionRef.current?.scrollIntoView({ block: 'nearest' })
requestAnimationFrame(() => {
navigationalScrollRef.current = false
})
}, [activeIndex, renderedOptions.length, windowStart])
React.useEffect(() => {
if (!disabled || !open) return
setOpen(false)
setActiveIndex(-1)
setIsSearching(false)
onInputValueChange?.(value ?? "")
}, [disabled, open, onInputValueChange, value])
const handleSelect = (optValue: string) => {
if (disabled) return
onValueChange?.(optValue)
onInputValueChange?.(optValue)
setOpen(false)
setActiveIndex(-1)
const selected = options.find((opt) => opt.value === optValue)
setInputValue(selected?.label || optValue)
}
const handleCreate = () => {
if (disabled) return
const newValue = inputValue.trim()
if (newValue) {
onCreateNew?.(newValue)
onValueChange?.(newValue)
onInputValueChange?.(newValue)
setOpen(false)
setActiveIndex(-1)
}
}
const focusAndSelectInput = () => {
// Defer so selection wins over click-to-place-caret on focus.
requestAnimationFrame(() => {
focusComboboxInput(inputRef.current, true)
})
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
onInputValueChange?.(e.target.value)
setIsSearching(true)
setActiveIndex(-1)
if (!open) setOpen(true)
}
const handleInputFocus = () => {
if (selectValueOnFocus) focusAndSelectInput()
}
const handleOpenChange = (nextOpen: boolean) => {
if (!canComboboxOpen(disabled, nextOpen)) return
setOpen(nextOpen)
setActiveIndex(-1)
if (nextOpen) {
if (selectValueOnFocus) {
// Opening a closed picker from its chevron should also replace on first keystroke.
focusAndSelectInput()
}
} else {
onInputValueChange?.(value ?? "")
}
}
const handleInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault()
if (!open) setOpen(true)
const direction = e.key === 'ArrowDown' ? 1 : -1
setActiveIndex((current) =>
getNextComboboxActiveIndex(current, selectableOptionCount, direction)
)
} else if (e.key === 'Enter') {
e.preventDefault()
if (hasActiveOption) {
if (showCreateOption && activeIndex === 0) {
handleCreate()
} else {
const optionIndex = activeIndex - (showCreateOption ? 1 : 0)
const activeOption = filteredOptions[optionIndex]
if (activeOption) handleSelect(activeOption.value)
}
} else if (showCreateOption) {
handleCreate()
} else if (filteredOptions.length === 1) {
handleSelect(filteredOptions[0].value)
}
} else if (e.key === 'Escape') {
handleOpenChange(false)
}
}
const handleClear = (e: React.MouseEvent) => {
if (disabled) return
e.stopPropagation()
setInputValue("")
onInputValueChange?.("")
onValueChange?.("")
inputRef.current?.focus()
}
return (
<Popover open={open && !disabled} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild disabled={disabled}>
<div
aria-disabled={disabled}
className={cn(
"flex h-10 w-full items-center rounded-md border border-input bg-background text-sm min-w-0 overflow-hidden",
"hover:bg-secondary/50 transition-colors",
"focus-within:outline-none focus-within:ring-1 focus-within:ring-ring",
disabled && "cursor-not-allowed opacity-50 hover:bg-background",
triggerClassName
)}
>
{icon && <span className="pl-3 shrink-0 text-muted-foreground">{icon}</span>}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onFocus={handleInputFocus}
onKeyDown={handleInputKeyDown}
role="combobox"
aria-label={ariaLabel}
aria-autocomplete="list"
aria-expanded={open && !disabled}
aria-controls={listboxId}
aria-activedescendant={
open && !disabled && hasActiveOption
? `${listboxId}-option-${activeIndex}`
: undefined
}
placeholder={placeholder}
style={inputStyle}
className="flex-1 min-w-0 h-full px-3 bg-transparent outline-none placeholder:text-muted-foreground"
disabled={disabled}
/>
{clearable && !disabled && inputValue && (
<button
type="button"
onClick={handleClear}
className="pr-1 text-muted-foreground hover:text-foreground"
>
<X size={14} />
</button>
)}
<ChevronDown className="h-4 w-4 shrink-0 opacity-50 pr-3 box-content" />
</div>
</PopoverTrigger>
<PopoverContent
className={cn("app-no-drag p-0 border-border/60", className)}
align="start"
sideOffset={4}
onOpenAutoFocus={(e) => e.preventDefault()}
style={{ width: 'var(--radix-popover-trigger-width)' }}
>
{/* Options List */}
<ComboboxOptionsList id={listboxId} listbox scrollRef={optionsScrollRef} onScrollCapture={handleOptionsScrollCapture}>
{filteredOptions.length === 0 && !showCreateOption ? (
<div className="py-4 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : (
<>
{/* Create new option */}
{showCreateOption && (
<button
ref={activeIndex === 0 ? activeOptionRef : undefined}
id={`${listboxId}-option-0`}
role="option"
aria-selected={false}
aria-posinset={1}
aria-setsize={selectableOptionCount}
tabIndex={-1}
type="button"
className={cn(
"flex w-full items-center gap-3 px-3 py-2.5 rounded-md text-sm hover:bg-secondary/80 transition-colors text-left",
activeIndex === 0 && "bg-secondary/80",
)}
onClick={handleCreate}
onMouseEnter={() => setActiveIndex(0)}
>
<Plus size={16} className="text-primary shrink-0" />
<span className="text-muted-foreground">{createText}</span>
<span className="font-medium text-foreground">{inputValue}</span>
</button>
)}
{/* Separator if both create and options exist */}
{showCreateOption && filteredOptions.length > 0 && (
<div className="h-px bg-border/60 my-1" />
)}
{/* Existing options (rendered incrementally for large lists) */}
{renderedOptions.map((option, optionIndex) => {
const selectableIndex = optionIndex + windowStart + (showCreateOption ? 1 : 0)
return (
<button
key={option.value}
ref={activeIndex === selectableIndex ? activeOptionRef : undefined}
id={`${listboxId}-option-${selectableIndex}`}
role="option"
aria-selected={value === option.value}
aria-posinset={selectableIndex + 1}
aria-setsize={selectableOptionCount}
tabIndex={-1}
type="button"
className={cn(
"flex w-full items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left",
value === option.value
? "bg-primary/10 text-foreground"
: "hover:bg-secondary/80",
activeIndex === selectableIndex && "bg-secondary/80",
)}
onClick={() => handleSelect(option.value)}
onMouseEnter={() => setActiveIndex(selectableIndex)}
>
{option.icon && (
<span className="shrink-0 text-muted-foreground">{option.icon}</span>
)}
<div className="flex-1 min-w-0">
<div className="truncate font-medium" style={option.labelStyle}>{option.label}</div>
{option.sublabel && (
<div className="text-xs text-muted-foreground truncate">
{option.sublabel}
</div>
)}
</div>
{value === option.value && (
<Check size={16} className="shrink-0 text-primary" />
)}
</button>
)
})}
</>
)}
</ComboboxOptionsList>
</PopoverContent>
</Popover>
)
}
// Multi-select Combobox for tags
interface MultiComboboxProps {
options: ComboboxOption[];
values: string[];
onValuesChange?: (values: string[]) => void;
placeholder?: string;
emptyText?: string;
allowCreate?: boolean;
onCreateNew?: (value: string) => void;
createText?: string;
icon?: React.ReactNode;
className?: string;
triggerClassName?: string;
disabled?: boolean;
}
export function MultiCombobox({
options,
values,
onValuesChange,
placeholder = "Add...",
emptyText = "No results found",
allowCreate = false,
onCreateNew,
createText = "Create Tag",
icon,
className,
triggerClassName,
disabled = false,
}: MultiComboboxProps) {
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState("")
const inputRef = React.useRef<HTMLInputElement>(null)
const filteredOptions = React.useMemo(() => {
if (!inputValue.trim()) return options
const lower = inputValue.toLowerCase()
return options.filter(
(opt) =>
opt.label.toLowerCase().includes(lower) ||
opt.value.toLowerCase().includes(lower)
)
}, [options, inputValue])
const showCreateOption = React.useMemo(() => {
if (!allowCreate || !inputValue.trim()) return false
const lower = inputValue.toLowerCase().trim()
return !options.some((opt) => opt.value.toLowerCase() === lower || opt.label.toLowerCase() === lower)
}, [allowCreate, inputValue, options])
const handleToggle = (optValue: string) => {
const newValues = values.includes(optValue)
? values.filter((v) => v !== optValue)
: [...values, optValue]
onValuesChange?.(newValues)
}
const handleCreate = () => {
const newValue = inputValue.trim()
if (newValue && !values.includes(newValue)) {
onCreateNew?.(newValue)
onValuesChange?.([...values, newValue])
setInputValue("")
}
}
const handleRemove = (e: React.MouseEvent, val: string) => {
e.stopPropagation()
onValuesChange?.(values.filter((v) => v !== val))
}
const handleInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
if (showCreateOption) {
handleCreate()
} else if (filteredOptions.length === 1 && !values.includes(filteredOptions[0].value)) {
handleToggle(filteredOptions[0].value)
setInputValue("")
}
} else if (e.key === 'Escape') {
setOpen(false)
} else if (e.key === 'Backspace' && !inputValue && values.length > 0) {
// Remove last tag on backspace when input is empty
onValuesChange?.(values.slice(0, -1))
}
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild disabled={disabled}>
<div
className={cn(
"flex min-h-10 w-full items-center gap-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm",
"hover:bg-secondary/50 transition-colors cursor-text",
"disabled:cursor-not-allowed disabled:opacity-50",
triggerClassName
)}
onClick={() => inputRef.current?.focus()}
>
{icon && <span className="pl-1 shrink-0 text-muted-foreground">{icon}</span>}
<div className="flex-1 flex flex-wrap gap-1.5 items-center min-w-0">
{values.map((val) => (
<span
key={val}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-primary/10 text-primary text-xs font-medium"
>
{val}
<button
type="button"
onClick={(e) => handleRemove(e, val)}
className="hover:bg-primary/20 rounded p-0.5"
>
<X size={12} />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
if (!open) setOpen(true)
}}
onKeyDown={handleInputKeyDown}
placeholder={values.length === 0 ? placeholder : ""}
className="flex-1 min-w-[60px] h-6 bg-transparent outline-none placeholder:text-muted-foreground text-sm"
disabled={disabled}
/>
</div>
</div>
</PopoverTrigger>
<PopoverContent
className={cn("app-no-drag p-0 border-border/60", className)}
align="start"
sideOffset={4}
onOpenAutoFocus={(e) => e.preventDefault()}
style={{ width: 'var(--radix-popover-trigger-width)' }}
>
{/* Options List */}
<ComboboxOptionsList>
{filteredOptions.length === 0 && !showCreateOption ? (
<div className="py-4 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : (
<>
{/* Create new option */}
{showCreateOption && (
<button
type="button"
className="flex w-full items-center gap-3 px-3 py-2.5 rounded-md text-sm hover:bg-secondary/80 transition-colors text-left"
onClick={handleCreate}
>
<Plus size={16} className="text-primary shrink-0" />
<span className="text-muted-foreground">{createText}</span>
<span className="font-medium text-foreground">{inputValue}</span>
</button>
)}
{/* Separator if both create and options exist */}
{showCreateOption && filteredOptions.length > 0 && (
<div className="h-px bg-border/60 my-1" />
)}
{/* Existing options */}
{filteredOptions.map((option) => {
const isSelected = values.includes(option.value)
return (
<button
key={option.value}
type="button"
className={cn(
"flex w-full items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left",
isSelected
? "bg-primary/10 text-foreground"
: "hover:bg-secondary/80"
)}
onClick={() => {
handleToggle(option.value)
setInputValue("")
}}
>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center shrink-0",
isSelected ? "bg-primary border-primary" : "border-muted-foreground/40"
)}>
{isSelected && <Check size={12} className="text-primary-foreground" />}
</div>
<span className="truncate flex-1">{option.label}</span>
</button>
)
})}
</>
)}
</ComboboxOptionsList>
</PopoverContent>
</Popover>
)
}
export default Combobox

View File

@@ -0,0 +1,68 @@
import React, { memo } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Button } from "./button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./dialog";
interface ConfirmDialogProps {
open: boolean;
title: string;
message?: string;
confirmLabel: string;
busy?: boolean;
destructive?: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}
export const ConfirmDialog = memo(function ConfirmDialog({
open,
title,
message,
confirmLabel,
busy = false,
destructive = false,
onOpenChange,
onConfirm,
}: ConfirmDialogProps) {
const { t } = useI18n();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-[380px]">
<DialogHeader className="min-w-0 pr-6">
<DialogTitle className="truncate">{title}</DialogTitle>
</DialogHeader>
{message ? (
<p className="min-w-0 whitespace-pre-wrap break-words text-sm text-muted-foreground [overflow-wrap:anywhere]">{message}</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={busy}
>
{t("common.cancel")}
</Button>
<Button
type="button"
variant={destructive ? "destructive" : "default"}
onClick={onConfirm}
disabled={busy}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -0,0 +1,272 @@
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
const menuCollisionPadding = { top: 36, bottom: 12, left: 12, right: 12 };
const CONTEXT_MENU_PORTAL_ID = "netcatty-context-menu-root";
// Dedicated portal root so context menus always sit above every other layer (dialogs, titlebar, overlays)
const getContextMenuPortalEl = () => {
if (typeof document === "undefined") return null;
let portal = document.getElementById(CONTEXT_MENU_PORTAL_ID);
if (!portal) {
portal = document.createElement("div");
portal.id = CONTEXT_MENU_PORTAL_ID;
Object.assign(portal.style, {
position: "fixed",
inset: "0px",
zIndex: "2147483647", // max safe z-index to avoid being covered
pointerEvents: "none",
});
// Intercept aria-hidden attribute to prevent it from being set when menu is open
// This avoids "Blocked aria-hidden on an element because its descendant retained focus" warnings
let ariaHiddenValue: string | null = null;
Object.defineProperty(portal, "ariaHidden", {
get() {
return ariaHiddenValue;
},
set(value: string | null) {
// Block aria-hidden="true" when there are children (menu is open)
if (value === "true" && portal && portal.children.length > 0) {
return;
}
ariaHiddenValue = value;
},
configurable: true,
});
// Also override setAttribute for aria-hidden
const originalSetAttribute = portal.setAttribute.bind(portal);
portal.setAttribute = function (name: string, value: string) {
if (name === "aria-hidden" && value === "true" && portal && portal.children.length > 0) {
return;
}
originalSetAttribute(name, value);
};
// Override removeAttribute to sync our internal state
const originalRemoveAttribute = portal.removeAttribute.bind(portal);
portal.removeAttribute = function (name: string) {
if (name === "aria-hidden") {
ariaHiddenValue = null;
}
originalRemoveAttribute(name);
};
document.body.appendChild(portal);
}
return portal;
};
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => {
const portalContainer = React.useMemo(
() => getContextMenuPortalEl() ?? undefined,
[],
);
return (
<ContextMenuPrimitive.Portal container={portalContainer}>
<ContextMenuPrimitive.SubContent
ref={ref}
sideOffset={6}
collisionPadding={menuCollisionPadding}
className={cn(
"z-[200000] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg app-no-drag pointer-events-auto data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
});
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => {
const portalContainer = React.useMemo(
() => getContextMenuPortalEl() ?? undefined,
[],
);
return (
<ContextMenuPrimitive.Portal container={portalContainer}>
<ContextMenuPrimitive.Content
ref={ref}
collisionPadding={menuCollisionPadding}
className={cn(
"z-[200000] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 app-no-drag pointer-events-auto data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
});
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center whitespace-nowrap rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto shrink-0 pl-4 text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuPortal,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
};

131
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,131 @@
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from "react"
import { useI18n } from "../../application/i18n/I18nProvider"
import { cn } from "../../lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean; overlayClassName?: string }
>(({ className, children, hideCloseButton, overlayClassName, ...props }, ref) => {
const { t } = useI18n()
return (
<DialogPortal>
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
style={{ boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 12px 24px -8px rgba(0, 0, 0, 0.15)' }}
aria-describedby={undefined}
{...props}
>
{children}
<DialogPrimitive.Close
data-dialog-close="true"
tabIndex={-1}
aria-hidden="true"
className="sr-only"
>
{t("common.close")}
</DialogPrimitive.Close>
{!hideCloseButton && (
<DialogPrimitive.Close
data-dialog-close="true"
className="absolute right-4 top-4 rounded-md p-1 transition-all hover:bg-muted hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none text-muted-foreground"
>
<X className="h-4 w-4" />
<span className="sr-only">{t("common.close")}</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
})
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-snug tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger
}

310
components/ui/dropdown.tsx Normal file
View File

@@ -0,0 +1,310 @@
import * as React from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { cn } from "../../lib/utils";
interface DropdownContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<HTMLButtonElement | null>;
}
const DropdownContext = React.createContext<DropdownContextValue | null>(null);
function useDropdown() {
const context = React.useContext(DropdownContext);
if (!context) {
throw new Error("Dropdown components must be used within a Dropdown");
}
return context;
}
interface DropdownProps {
children: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
const Dropdown: React.FC<DropdownProps> = ({
children,
open: controlledOpen,
onOpenChange,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
const setOpen = useCallback(
(value: boolean) => {
if (controlledOpen === undefined) {
setInternalOpen(value);
}
onOpenChange?.(value);
},
[controlledOpen, onOpenChange],
);
useEffect(() => {
const closeOnPageHidden = () => {
if (document.visibilityState === "hidden") {
setOpen(false);
}
};
document.addEventListener("visibilitychange", closeOnPageHidden);
return () => document.removeEventListener("visibilitychange", closeOnPageHidden);
}, [setOpen]);
return (
<DropdownContext.Provider value={{ open, setOpen, triggerRef }}>
{children}
</DropdownContext.Provider>
);
};
interface DropdownTriggerProps {
children: React.ReactElement;
asChild?: boolean;
toggleOnClick?: boolean;
}
const DropdownTrigger: React.FC<DropdownTriggerProps> = ({
children,
asChild,
toggleOnClick = true,
}) => {
const { open, setOpen, triggerRef } = useDropdown();
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
setOpen(!open);
};
if (asChild && React.isValidElement(children)) {
return React.cloneElement(
children as React.ReactElement<{
ref?: React.Ref<HTMLButtonElement>;
onClick?: (e: React.MouseEvent) => void;
}>,
{
ref: triggerRef,
onClick: (e: React.MouseEvent) => {
const childProps = children.props as {
onClick?: (e: React.MouseEvent) => void;
};
childProps?.onClick?.(e);
if (toggleOnClick && !e.defaultPrevented) {
handleClick(e);
}
},
},
);
}
return (
<button ref={triggerRef} onClick={handleClick}>
{children}
</button>
);
};
interface DropdownContentProps {
children: React.ReactNode;
className?: string;
align?: "start" | "center" | "end";
sideOffset?: number;
side?: "top" | "bottom";
/** If true, align to the trigger's parent element instead of the trigger itself */
alignToParent?: boolean;
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
}
const DropdownContent: React.FC<DropdownContentProps> = ({
children,
className,
align = "start",
sideOffset = 4,
side = "bottom",
alignToParent = false,
onMouseEnter,
onMouseLeave,
}) => {
const { open, setOpen, triggerRef } = useDropdown();
const contentRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<{
top: number;
left: number;
maxHeight?: number;
} | null>(null);
// Calculate position function
const calculatePosition = useCallback(() => {
if (!triggerRef.current) return null;
// Use parent element if alignToParent is true
const anchorEl = alignToParent
? triggerRef.current.parentElement
: triggerRef.current;
if (!anchorEl) return null;
const rect = anchorEl.getBoundingClientRect();
const triggerRect = triggerRef.current.getBoundingClientRect();
const contentEl = contentRef.current;
const contentHeight = contentEl?.offsetHeight ?? 0;
const contentWidth = contentEl?.offsetWidth ?? 0;
const viewportMargin = 8;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const spaceBelow = Math.max(
0,
viewportHeight - triggerRect.bottom - sideOffset - viewportMargin,
);
const spaceAbove = Math.max(
0,
triggerRect.top - sideOffset - viewportMargin,
);
// Prefer requested side when it fits; otherwise pick the side with more room.
// Never bounce oversized menus off-screen by flipping twice.
let placeBelow: boolean;
if (side === "bottom") {
if (contentHeight <= spaceBelow || spaceBelow >= spaceAbove) {
placeBelow = true;
} else {
placeBelow = false;
}
} else if (contentHeight <= spaceAbove || spaceAbove > spaceBelow) {
placeBelow = false;
} else {
placeBelow = true;
}
const available = placeBelow ? spaceBelow : spaceAbove;
const maxHeight =
contentHeight > available && available > 0
? available
: undefined;
const effectiveHeight = maxHeight ?? contentHeight;
let top: number;
if (placeBelow) {
top = triggerRect.bottom + sideOffset;
} else {
top = triggerRect.top - effectiveHeight - sideOffset;
if (top < viewportMargin) {
top = viewportMargin;
}
}
// Use anchor element (parent or trigger) for horizontal positioning
let left: number;
if (align === "start") {
left = rect.left;
} else if (align === "end") {
left = contentEl ? rect.right - contentWidth : rect.right;
} else {
left = rect.left + rect.width / 2;
if (contentEl) {
left -= contentWidth / 2;
}
}
if (contentEl) {
if (left + contentWidth > viewportWidth - viewportMargin) {
left = viewportWidth - contentWidth - viewportMargin;
}
if (left < viewportMargin) {
left = viewportMargin;
}
}
return { top, left, maxHeight };
}, [align, sideOffset, side, alignToParent, triggerRef]);
// Calculate position synchronously after DOM updates
useLayoutEffect(() => {
if (open) {
// Reset position first to hide content while calculating
setPosition(null);
// Use double requestAnimationFrame to ensure content is fully rendered
// First frame: content is added to DOM
// Second frame: layout is calculated, offsetWidth is available
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const pos = calculatePosition();
if (pos) setPosition(pos);
});
});
} else {
setPosition(null);
}
}, [open, calculatePosition]);
// Close on click outside
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (
contentRef.current &&
!contentRef.current.contains(e.target as Node) &&
triggerRef.current &&
!triggerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
}
};
// Use setTimeout to avoid closing immediately on the same click that opened it
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
}, 0);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [open, setOpen, triggerRef]);
if (!open) return null;
return createPortal(
<div
ref={contentRef}
className={cn(
"fixed z-[999999] rounded-md border border-border/60 bg-popover p-1 text-popover-foreground shadow-md",
className,
)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
style={{
top: position?.top ?? -9999,
left: position?.left ?? -9999,
maxHeight: position?.maxHeight,
overflowY: position?.maxHeight ? "auto" : undefined,
visibility: position ? "visible" : "hidden",
}}
>
{children}
</div>,
document.body,
);
};
export { Dropdown, DropdownContent, DropdownTrigger };

View File

@@ -0,0 +1,30 @@
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import * as React from "react"
import { cn } from "../../lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-[999999] rounded-md border border-border/60 bg-popover p-4 text-popover-foreground shadow-md outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@@ -0,0 +1,90 @@
import { cn } from '../../lib/utils';
import type { ComponentProps, HTMLAttributes } from 'react';
import { forwardRef } from 'react';
export type InputGroupProps = HTMLAttributes<HTMLDivElement>;
export const InputGroup = forwardRef<HTMLDivElement, InputGroupProps>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'flex flex-col rounded-xl border border-border/65 bg-background transition-[border-color,background-color]',
'focus-within:border-primary/45 focus-within:ring-1 focus-within:ring-primary/20',
'overflow-hidden',
className,
)}
{...props}
/>
),
);
InputGroup.displayName = 'InputGroup';
export type InputGroupTextareaProps = ComponentProps<'textarea'>;
export const InputGroupTextarea = forwardRef<HTMLTextAreaElement, InputGroupTextareaProps>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
'w-full resize-none bg-transparent text-[13px] text-foreground/92 selection:bg-primary/25',
'placeholder:text-muted-foreground/62 placeholder:font-medium placeholder:text-[13px]',
'focus:outline-none disabled:opacity-40 disabled:cursor-not-allowed',
'px-4 pt-3.5 pb-2 leading-[20px]',
'field-sizing-content min-h-[82px] max-h-52',
className,
)}
{...props}
/>
),
);
InputGroupTextarea.displayName = 'InputGroupTextarea';
export type InputGroupAddonProps = HTMLAttributes<HTMLDivElement> & {
align?: 'block-start' | 'block-end';
};
export const InputGroupAddon = forwardRef<HTMLDivElement, InputGroupAddonProps>(
({ className, align = 'block-end', ...props }, ref) => (
<div
ref={ref}
className={cn(
'flex items-center px-2.5 py-1.5',
align === 'block-start' && 'border-b border-border/35 bg-muted/8',
align === 'block-end' && 'border-t border-border/60 bg-muted/10',
className,
)}
{...props}
/>
),
);
InputGroupAddon.displayName = 'InputGroupAddon';
export type InputGroupButtonProps = ComponentProps<'button'> & {
variant?: 'default' | 'ghost' | 'outline' | 'destructive';
size?: 'sm' | 'icon-sm' | 'default';
};
export const InputGroupButton = forwardRef<HTMLButtonElement, InputGroupButtonProps>(
({ className, variant = 'ghost', size = 'icon-sm', disabled, ...props }, ref) => (
<button
ref={ref}
type="button"
disabled={disabled}
className={cn(
'inline-flex items-center justify-center rounded-md transition-colors cursor-pointer',
'disabled:opacity-30 disabled:cursor-default',
size === 'icon-sm' && 'h-7 w-7',
size === 'sm' && 'h-7 px-2 text-[12px] gap-1',
size === 'default' && 'h-8 px-3 text-[13px] gap-1.5',
variant === 'ghost' && 'text-muted-foreground/78 hover:text-foreground hover:bg-muted/45',
variant === 'default' && 'bg-primary/80 text-primary-foreground hover:bg-primary',
variant === 'outline' && 'border border-border/40 text-muted-foreground/85 hover:text-foreground hover:bg-muted/35',
variant === 'destructive' && 'text-destructive/70 hover:text-destructive hover:bg-destructive/10',
className,
)}
{...props}
/>
),
);
InputGroupButton.displayName = 'InputGroupButton';

24
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> { }
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

19
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "../../lib/utils"
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-5 peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
))
Label.displayName = "Label"
export { Label }

View File

@@ -0,0 +1,72 @@
import React, { Component } from "react";
import { cn } from "../../lib/utils";
type LazyLoadBoundaryProps = {
children: React.ReactNode;
className?: string;
fallback?: React.ReactNode | ((error: Error) => React.ReactNode);
name?: string;
resetKey?: React.Key | null;
};
type LazyLoadBoundaryState = {
error: Error | null;
retryKey: number;
};
export class LazyLoadBoundary extends Component<LazyLoadBoundaryProps, LazyLoadBoundaryState> {
declare props: Readonly<LazyLoadBoundaryProps>;
declare setState: React.Component<LazyLoadBoundaryProps, LazyLoadBoundaryState>["setState"];
state: LazyLoadBoundaryState = { error: null, retryKey: 0 };
static getDerivedStateFromError(error: Error): Partial<LazyLoadBoundaryState> {
return { error };
}
componentDidUpdate(prevProps: LazyLoadBoundaryProps) {
if (prevProps.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null });
}
}
private retry = () => {
if (typeof window !== "undefined" && typeof window.location?.reload === "function") {
window.location.reload();
return;
}
this.setState(({ retryKey }) => ({ error: null, retryKey: retryKey + 1 }));
};
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error(`[LazyLoadBoundary] ${this.props.name || "content"} failed:`, error, errorInfo.componentStack);
}
render() {
if (this.state.error) {
const { fallback } = this.props;
if (typeof fallback === "function") return fallback(this.state.error);
if (fallback) return fallback;
const label = this.props.name || "This area";
return (
<div
className={cn(
"flex h-full min-h-[120px] flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground",
this.props.className,
)}
role="alert"
>
<div className="font-medium text-foreground">{label} could not load.</div>
<button
type="button"
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
onClick={this.retry}
>
Reload
</button>
</div>
);
}
return <React.Fragment key={this.state.retryKey}>{this.props.children}</React.Fragment>;
}
}

75
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,75 @@
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from "react"
import { useCallback, useLayoutEffect, useState } from "react"
import { cn } from "../../lib/utils"
import { usePortalContainer } from "./portal-container"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverClose = PopoverPrimitive.Close
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => {
const portalContainer = usePortalContainer()
// Fix for Electron: ensure position is calculated after content is fully rendered
const [isPositioned, setIsPositioned] = useState(false)
const [node, setNode] = useState<HTMLDivElement | null>(null)
// Use callback ref to detect when element is mounted
const callbackRef = useCallback((element: HTMLDivElement | null) => {
setNode(element)
// Forward ref
if (typeof ref === 'function') {
ref(element)
} else if (ref) {
ref.current = element
}
}, [ref])
useLayoutEffect(() => {
if (!node) {
setIsPositioned(false)
return
}
// Element just mounted, wait for next frame to ensure position is calculated
setIsPositioned(false)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setIsPositioned(true)
})
})
}, [node])
return (
<PopoverPrimitive.Portal container={portalContainer ?? undefined}>
<PopoverPrimitive.Content
ref={callbackRef}
align={align}
sideOffset={sideOffset}
// Force position recalculation on every animation frame
updatePositionStrategy="always"
avoidCollisions={true}
collisionPadding={8}
style={{
visibility: isPositioned ? 'visible' : 'hidden',
}}
className={cn(
"z-[999999] rounded-md border border-border/60 bg-popover p-4 text-popover-foreground shadow-md outline-none pointer-events-auto",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
})
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger }

View File

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { PortalContainerProvider, usePortalContainer } from './portal-container';
test('portal container is available to nested editor controls', () => {
const marker = {} as HTMLElement;
const Probe = () => <span>{usePortalContainer() === marker ? 'inside' : 'outside'}</span>;
assert.equal(
renderToStaticMarkup(
<PortalContainerProvider container={marker}>
<Probe />
</PortalContainerProvider>,
),
'<span>inside</span>',
);
});
test('editor dropdown primitives use the scoped portal container', () => {
for (const file of ['popover.tsx', 'select.tsx', 'tooltip.tsx']) {
const source = readFileSync(new URL(`./${file}`, import.meta.url), 'utf8');
assert.match(source, /usePortalContainer\(\)/);
assert.match(source, /Portal container=\{portalContainer \?\? undefined\}/);
}
});

View File

@@ -0,0 +1,14 @@
import React, { createContext, useContext } from 'react';
const PortalContainerContext = createContext<HTMLElement | null>(null);
export const PortalContainerProvider: React.FC<{
container: HTMLElement | null;
children: React.ReactNode;
}> = ({ container, children }) => (
<PortalContainerContext.Provider value={container}>
{children}
</PortalContainerContext.Provider>
);
export const usePortalContainer = (): HTMLElement | null => useContext(PortalContainerContext);

View File

@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
primaryOnlyDragHandlers,
restorePrimaryOnlyDrag,
} from "./primaryOnlyDrag.ts";
const fakeRoot = () => {
const listeners = new Map<string, Set<EventListener>>();
return {
listeners,
addEventListener(type: string, listener: EventListener) {
const set = listeners.get(type) ?? new Set();
set.add(listener);
listeners.set(type, set);
},
removeEventListener(type: string, listener: EventListener) {
listeners.get(type)?.delete(listener);
},
dispatch(type: string) {
for (const listener of Array.from(listeners.get(type) ?? [])) {
listener(new Event(type));
}
},
};
};
test("primaryOnlyDragHandlers restores draggable after a right-click is released outside", () => {
const root = fakeRoot();
const target = { draggable: true } as HTMLElement;
const handlers = primaryOnlyDragHandlers(true, root);
handlers.onPointerDown({
button: 2,
currentTarget: target,
} as Parameters<typeof handlers.onPointerDown>[0]);
assert.equal(target.draggable, false);
assert.equal(root.listeners.get("pointerup")?.size, 1);
assert.equal(root.listeners.get("pointercancel")?.size, 1);
root.dispatch("pointerup");
assert.equal(target.draggable, true);
assert.equal(root.listeners.get("pointerup")?.size, 0);
assert.equal(root.listeners.get("pointercancel")?.size, 0);
});
test("primaryOnlyDragHandlers pointercancel on the document also restores draggable", () => {
const root = fakeRoot();
const target = { draggable: true } as HTMLElement;
const handlers = primaryOnlyDragHandlers(true, root);
handlers.onPointerDown({
button: 2,
currentTarget: target,
} as Parameters<typeof handlers.onPointerDown>[0]);
root.dispatch("pointercancel");
assert.equal(target.draggable, true);
assert.equal(root.listeners.get("pointerup")?.size, 0);
assert.equal(root.listeners.get("pointercancel")?.size, 0);
});
test("element-level pointerup clears the document restore listeners", () => {
const root = fakeRoot();
const target = { draggable: true } as HTMLElement;
const handlers = primaryOnlyDragHandlers(true, root);
handlers.onPointerDown({
button: 2,
currentTarget: target,
} as Parameters<typeof handlers.onPointerDown>[0]);
handlers.onPointerUp({
currentTarget: target,
} as Parameters<typeof handlers.onPointerUp>[0]);
assert.equal(target.draggable, true);
assert.equal(root.listeners.get("pointerup")?.size, 0);
restorePrimaryOnlyDrag(target, false);
assert.equal(target.draggable, false);
});
test("primary pointer down does not disable dragging", () => {
const root = fakeRoot();
const target = { draggable: true } as HTMLElement;
const handlers = primaryOnlyDragHandlers(true, root);
handlers.onPointerDown({
button: 0,
currentTarget: target,
} as Parameters<typeof handlers.onPointerDown>[0]);
assert.equal(target.draggable, true);
assert.equal(root.listeners.get("pointerup")?.size ?? 0, 0);
});

View File

@@ -0,0 +1,58 @@
import type { PointerEvent as ReactPointerEvent } from "react";
export type PrimaryOnlyDragEventRoot = Pick<
EventTarget,
"addEventListener" | "removeEventListener"
>;
export const isNonPrimaryPointer = (event: { button?: number; buttons?: number }): boolean =>
event.button === 2
|| event.buttons === 2
|| (typeof event.button === "number" && event.button !== 0);
const pendingRestore = new WeakMap<HTMLElement, () => void>();
export function restorePrimaryOnlyDrag(target: HTMLElement, enabled: boolean): void {
const restore = pendingRestore.get(target);
if (restore) {
restore();
return;
}
target.draggable = enabled;
}
export function armPrimaryOnlyDragRestore(
target: HTMLElement,
enabled: boolean,
root: PrimaryOnlyDragEventRoot = window,
): void {
pendingRestore.get(target)?.();
const restore = () => {
target.draggable = enabled;
root.removeEventListener("pointerup", restore, true);
root.removeEventListener("pointercancel", restore, true);
if (pendingRestore.get(target) === restore) pendingRestore.delete(target);
};
pendingRestore.set(target, restore);
root.addEventListener("pointerup", restore, true);
root.addEventListener("pointercancel", restore, true);
}
export function primaryOnlyDragHandlers(
enabled: boolean,
root?: PrimaryOnlyDragEventRoot,
) {
return {
onPointerDown: (event: ReactPointerEvent<HTMLElement>) => {
if (event.button === 0) return;
event.currentTarget.draggable = false;
armPrimaryOnlyDragRestore(event.currentTarget, enabled, root ?? window);
},
onPointerUp: (event: ReactPointerEvent<HTMLElement>) => {
restorePrimaryOnlyDrag(event.currentTarget, enabled);
},
onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => {
restorePrimaryOnlyDrag(event.currentTarget, enabled);
},
};
}

63
components/ui/ripple.tsx Normal file
View File

@@ -0,0 +1,63 @@
import * as React from "react";
import { cn } from "../../lib/utils";
import { Button, ButtonProps } from "./button";
interface RippleState {
id: number;
x: number;
y: number;
size: number;
}
const RIPPLE_DURATION_MS = 600;
export const RippleButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ children, className, onPointerDown, ...props }, ref) => {
const [ripples, setRipples] = React.useState<RippleState[]>([]);
const nextId = React.useRef(0);
const handlePointerDown = React.useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2;
const x = e.clientX - rect.left - size / 2;
const y = e.clientY - rect.top - size / 2;
const id = nextId.current++;
setRipples((rs) => [...rs, { id, x, y, size }]);
window.setTimeout(
() => setRipples((rs) => rs.filter((r) => r.id !== id)),
RIPPLE_DURATION_MS,
);
onPointerDown?.(e);
},
[onPointerDown],
);
return (
<Button
ref={ref}
className={cn("relative overflow-hidden", className)}
onPointerDown={handlePointerDown}
{...props}
>
{children}
<span className="pointer-events-none absolute inset-0">
{ripples.map((r) => (
<span
key={r.id}
className="absolute rounded-full bg-current"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
animation: `ripple ${RIPPLE_DURATION_MS}ms ease-out forwards`,
}}
/>
))}
</span>
</Button>
);
},
);
RippleButton.displayName = "RippleButton";

View File

@@ -0,0 +1,46 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import * as React from "react"
import { cn } from "../../lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full max-h-[inherit] rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea,ScrollBar }

164
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,164 @@
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as React from "react"
import { cn } from "../../lib/utils"
import { usePortalContainer } from "./portal-container"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => {
const fitSelectedText = typeof className === "string" && !className.includes("w-full");
return (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full max-w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:min-w-0 [&>span]:truncate [&>span]:whitespace-nowrap",
fitSelectedText && "min-w-max",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
})
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
type SelectContentProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
hideScrollButtons?: boolean;
}
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
SelectContentProps
>(({ className, children, position = "popper", hideScrollButtons = false, ...props }, ref) => {
const portalContainer = usePortalContainer()
return (
<SelectPrimitive.Portal container={portalContainer ?? undefined}>
<SelectPrimitive.Content
ref={ref}
// Disable animations - they cause stacking/positioning issues on first open
className={cn(
"z-[200000] max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
{!hideScrollButtons && <SelectScrollUpButton />}
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
{!hideScrollButtons && <SelectScrollDownButton />}
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
})
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue
}

View File

@@ -0,0 +1,58 @@
import { Calendar,CalendarClock,Check,ChevronDown,ChevronUp,FolderTree,GripVertical,SortAsc,SortDesc } from 'lucide-react';
import React from 'react';
import { useI18n } from "../../application/i18n/I18nProvider";
import { Button } from './button';
import { Dropdown,DropdownContent,DropdownTrigger } from './dropdown';
export type SortMode = 'manual' | 'az' | 'za' | 'newest' | 'oldest' | 'group';
const SORT_OPTIONS: Record<SortMode, { labelKey: string; icon: React.ReactElement; triggerIcon: React.ReactElement }> = {
manual: { labelKey: 'sort.manual', icon: <GripVertical className="w-4 h-4 shrink-0" />, triggerIcon: <GripVertical className="w-4 h-4" /> },
az: { labelKey: 'sort.az', icon: <SortAsc className="w-4 h-4 shrink-0" />, triggerIcon: <SortAsc className="w-4 h-4" /> },
za: { labelKey: 'sort.za', icon: <SortDesc className="w-4 h-4 shrink-0" />, triggerIcon: <SortDesc className="w-4 h-4" /> },
newest: { labelKey: 'sort.newest', icon: <Calendar className="w-4 h-4 shrink-0" />, triggerIcon: <Calendar className="w-4 h-4" /> },
oldest: { labelKey: 'sort.oldest', icon: <CalendarClock className="w-4 h-4 shrink-0" />, triggerIcon: <CalendarClock className="w-4 h-4" /> },
group: { labelKey: 'sort.group', icon: <FolderTree className="w-4 h-4 shrink-0" />, triggerIcon: <FolderTree className="w-4 h-4" /> },
};
interface SortDropdownProps {
value: SortMode;
onChange: (mode: SortMode) => void;
className?: string;
modes?: SortMode[];
}
export const SortDropdown: React.FC<SortDropdownProps> = ({ value, onChange, className, modes }) => {
const [open, setOpen] = React.useState(false);
const { t } = useI18n();
const visibleModes = modes ?? (Object.keys(SORT_OPTIONS) as SortMode[]);
return (
<Dropdown open={open} onOpenChange={setOpen}>
<DropdownTrigger asChild>
<Button variant="ghost" size="icon" className={className || "h-8 w-8"}>
{SORT_OPTIONS[value].triggerIcon}
{open ? <ChevronUp size={10} className="ml-0.5" /> : <ChevronDown size={10} className="ml-0.5" />}
</Button>
</DropdownTrigger>
<DropdownContent className="w-44" align="end">
{visibleModes.map(mode => (
<Button
key={mode}
variant={value === mode ? 'secondary' : 'ghost'}
className="w-full justify-start gap-2 h-9"
onClick={() => {
onChange(mode);
setOpen(false);
}}
>
{SORT_OPTIONS[mode].icon} {t(SORT_OPTIONS[mode].labelKey)}
{value === mode && <Check size={12} className="ml-auto" />}
</Button>
))}
</DropdownContent>
</Dropdown>
);
};
export default SortDropdown;

View File

@@ -0,0 +1,9 @@
import { cn } from '../../lib/utils';
import { Loader2 } from 'lucide-react';
import type { ComponentProps } from 'react';
export type SpinnerProps = ComponentProps<typeof Loader2>;
export const Spinner = ({ className, size = 16, ...props }: SpinnerProps) => (
<Loader2 className={cn('animate-spin', className)} size={size} {...props} />
);

76
components/ui/switch.tsx Normal file
View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface SwitchProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ className, checked, onCheckedChange, disabled, ...props }, ref) => {
const {
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
'aria-describedby': ariaDescribedBy,
id,
...inputProps
} = props;
const handleClick = () => {
if (!disabled && onCheckedChange) {
onCheckedChange(!checked);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!disabled && onCheckedChange) {
onCheckedChange(!checked);
}
}
};
return (
<div
role="switch"
id={id}
aria-checked={checked}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
aria-describedby={ariaDescribedBy}
tabIndex={disabled ? -1 : 0}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-primary" : "bg-input",
disabled && "cursor-not-allowed opacity-50",
className
)}
>
<input
type="checkbox"
className="sr-only"
ref={ref}
checked={checked}
disabled={disabled}
tabIndex={-1}
aria-hidden="true"
onChange={(e) => onCheckedChange?.(e.target.checked)}
{...inputProps}
/>
<span
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
checked ? "translate-x-5" : "translate-x-0"
)}
/>
</div>
)
}
)
Switch.displayName = "Switch"
export { Switch }

53
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,53 @@
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from "react"
import { cn } from "../../lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 focus-visible:outline-none",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsContent, TabsList, TabsTrigger }

View File

@@ -0,0 +1,253 @@
import { Check,ChevronDown,ChevronUp,Pencil,Search,Tag,Trash2,X } from 'lucide-react';
import React from 'react';
import { cn } from '../../lib/utils';
import { Button } from './button';
import { Dropdown,DropdownContent,DropdownTrigger } from './dropdown';
import { Input } from './input';
import { ScrollArea } from './scroll-area';
interface TagFilterDropdownProps {
allTags: string[];
selectedTags: string[];
onChange: (tags: string[]) => void;
onEditTag?: (oldTag: string, newTag: string) => void;
onDeleteTag?: (tag: string) => void;
className?: string;
}
export const TagFilterDropdown: React.FC<TagFilterDropdownProps> = ({
allTags,
selectedTags,
onChange,
onEditTag,
onDeleteTag,
className,
}) => {
const [open, setOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const [editingTag, setEditingTag] = React.useState<string | null>(null);
const [editValue, setEditValue] = React.useState('');
const editInputRef = React.useRef<HTMLInputElement>(null);
const toggleTag = (tag: string) => {
if (selectedTags.includes(tag)) {
onChange(selectedTags.filter(t => t !== tag));
} else {
onChange([...selectedTags, tag]);
}
};
const clearAll = () => {
onChange([]);
};
const hasFilters = selectedTags.length > 0;
// Filter tags based on search query
const filteredTags = React.useMemo(() => {
if (!searchQuery.trim()) return allTags;
const query = searchQuery.toLowerCase();
return allTags.filter(tag => tag.toLowerCase().includes(query));
}, [allTags, searchQuery]);
// Start editing a tag
const startEditing = (tag: string, e: React.MouseEvent) => {
e.stopPropagation();
setEditingTag(tag);
setEditValue(tag);
setTimeout(() => editInputRef.current?.focus(), 0);
};
// Save edited tag
const saveEdit = () => {
if (editingTag && editValue.trim() && editValue !== editingTag && onEditTag) {
onEditTag(editingTag, editValue.trim());
// Update selected tags if the edited tag was selected
if (selectedTags.includes(editingTag)) {
onChange(selectedTags.map(t => t === editingTag ? editValue.trim() : t));
}
}
setEditingTag(null);
setEditValue('');
};
// Cancel editing
const cancelEdit = () => {
setEditingTag(null);
setEditValue('');
};
// Handle edit input key events
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
saveEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
}
};
// Delete a tag
const handleDelete = (tag: string, e: React.MouseEvent) => {
e.stopPropagation();
if (onDeleteTag) {
onDeleteTag(tag);
// Remove from selected tags if it was selected
if (selectedTags.includes(tag)) {
onChange(selectedTags.filter(t => t !== tag));
}
}
};
// Reset state when popover closes
React.useEffect(() => {
if (!open) {
setSearchQuery('');
setEditingTag(null);
setEditValue('');
}
}, [open]);
const canEdit = !!onEditTag;
const canDelete = !!onDeleteTag;
return (
<Dropdown open={open} onOpenChange={setOpen}>
<DropdownTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
className || "h-8 w-8",
hasFilters && "text-primary"
)}
>
<Tag size={14} />
{open ? <ChevronUp size={10} className="ml-0.5" /> : <ChevronDown size={10} className="ml-0.5" />}
</Button>
</DropdownTrigger>
<DropdownContent className="w-64" align="end">
{allTags.length === 0 ? (
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
No tags available
</div>
) : (
<>
{/* Search input */}
<div className="px-2 py-1.5">
<div className="relative">
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search tags"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="h-8 pl-7 text-sm"
/>
</div>
</div>
{hasFilters && (
<Button
variant="ghost"
className="w-full justify-start gap-2 h-8 text-muted-foreground"
onClick={clearAll}
>
Clear selection
</Button>
)}
<div className="h-px bg-border my-1" />
<ScrollArea className="max-h-[240px]">
<div className="space-y-0.5">
{filteredTags.length === 0 ? (
<div className="px-3 py-2 text-center text-sm text-muted-foreground">
No matching tags
</div>
) : (
filteredTags.map(tag => {
const isSelected = selectedTags.includes(tag);
const isEditing = editingTag === tag;
if (isEditing) {
return (
<div key={tag} className="flex items-center gap-1 px-2 py-1">
<Input
ref={editInputRef}
value={editValue}
onChange={e => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={saveEdit}
className="h-7 text-sm flex-1"
autoFocus
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={cancelEdit}
>
<X size={12} />
</Button>
</div>
);
}
return (
<div
key={tag}
className={cn(
"flex items-center gap-2 h-8 px-2 rounded-md cursor-pointer group",
isSelected ? "bg-secondary" : "hover:bg-muted/60"
)}
onClick={() => toggleTag(tag)}
>
<div
className={cn(
"h-3 w-3 rounded-full border shrink-0",
isSelected ? "bg-primary border-primary" : "border-muted-foreground"
)}
/>
<span className="truncate flex-1 text-sm">{tag}</span>
{isSelected && <Check size={12} className="shrink-0 text-primary" />}
{/* Edit & Delete buttons - show on hover when handlers provided */}
{(canEdit || canDelete) && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{canEdit && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => startEditing(tag, e)}
>
<Pencil size={12} />
</Button>
)}
{canDelete && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={(e) => handleDelete(tag, e)}
>
<Trash2 size={12} />
</Button>
)}
</div>
)}
</div>
);
})
)}
</div>
</ScrollArea>
</>
)}
</DropdownContent>
</Dropdown>
);
};
export default TagFilterDropdown;

View File

@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "../../lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

194
components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,194 @@
import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { setNotify } from '../../application/notification';
import { cn } from '../../lib/utils';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
type: ToastType;
title?: string;
message: string;
duration?: number;
onClick?: () => void;
actionLabel?: string;
}
interface ToastActionsValue {
showToast: (toast: Omit<Toast, 'id'>) => void;
dismissToast: (id: string) => void;
}
interface ToastStateValue {
toasts: Toast[];
}
const ToastActionsContext = createContext<ToastActionsValue | null>(null);
const ToastStateContext = createContext<ToastStateValue | null>(null);
/** Actions-only subscription — does not re-render when the toast list changes. */
export const useToastActions = () => {
const context = useContext(ToastActionsContext);
if (!context) {
throw new Error('useToastActions must be used within a ToastProvider');
}
return context;
};
/** Full toast API. Prefer useToastActions when you do not need the list. */
export const useToast = () => {
const actions = useContext(ToastActionsContext);
const state = useContext(ToastStateContext);
if (!actions || !state) {
throw new Error('useToast must be used within a ToastProvider');
}
return { ...state, ...actions };
};
// Simple hook for components that may not be inside ToastProvider
let globalShowToast: ((toast: Omit<Toast, 'id'>) => void) | null = null;
export interface ToastOptions {
title?: string;
duration?: number;
onClick?: () => void;
actionLabel?: string;
}
export const toast = {
success: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'success', message, duration: 3000, ...options });
},
error: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'error', message, duration: 5000, ...options });
},
warning: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'warning', message, duration: 4000, ...options });
},
info: (message: string, titleOrOptions?: string | ToastOptions) => {
const options = typeof titleOrOptions === 'string' ? { title: titleOrOptions } : titleOrOptions;
globalShowToast?.({ type: 'info', message, duration: 3000, ...options });
},
};
const TOAST_ICONS: Record<ToastType, React.ReactNode> = {
success: <CheckCircle className="h-4 w-4 text-emerald-500" />,
error: <AlertCircle className="h-4 w-4 text-red-500" />,
warning: <AlertTriangle className="h-4 w-4 text-yellow-500" />,
info: <Info className="h-4 w-4 text-blue-500" />,
};
const TOAST_STYLES: Record<ToastType, string> = {
success: 'border-emerald-600 bg-emerald-50 dark:bg-emerald-950',
error: 'border-red-600 bg-red-50 dark:bg-red-950',
warning: 'border-yellow-600 bg-yellow-50 dark:bg-yellow-950',
info: 'border-blue-600 bg-blue-50 dark:bg-blue-950',
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((nextToast: Omit<Toast, 'id'>) => {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const created: Toast = { ...nextToast, id };
setToasts(prev => [...prev, created]);
// Auto dismiss
if (nextToast.duration !== 0) {
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, nextToast.duration || 4000);
}
}, []);
const dismissToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const actionsValue = useMemo<ToastActionsValue>(
() => ({ showToast, dismissToast }),
[showToast, dismissToast],
);
const stateValue = useMemo<ToastStateValue>(
() => ({ toasts }),
[toasts],
);
// Register global toast function
useEffect(() => {
globalShowToast = showToast;
setNotify(toast);
return () => {
globalShowToast = null;
};
}, [showToast]);
return (
<ToastActionsContext.Provider value={actionsValue}>
<ToastStateContext.Provider value={stateValue}>
{children}
<ToastContainer />
</ToastStateContext.Provider>
</ToastActionsContext.Provider>
);
};
const ToastContainer: React.FC = () => {
const { toasts } = useContext(ToastStateContext) ?? { toasts: [] as Toast[] };
const actions = useContext(ToastActionsContext);
const onDismiss = actions?.dismissToast;
if (toasts.length === 0 || !onDismiss) return null;
const handleToastClick = (t: Toast) => {
if (t.onClick) {
t.onClick();
onDismiss(t.id);
}
};
return (
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm">
{toasts.map(t => (
<div
key={t.id}
className={cn(
"flex items-start gap-3 p-3 rounded-lg border shadow-lg",
"bg-card animate-in slide-in-from-right-5 fade-in duration-200",
TOAST_STYLES[t.type],
t.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
)}
onClick={() => handleToastClick(t)}
role={t.onClick ? "button" : undefined}
tabIndex={t.onClick ? 0 : undefined}
>
<div className="flex-shrink-0 mt-0.5">
{TOAST_ICONS[t.type]}
</div>
<div className="flex-1 min-w-0">
{t.title && (
<div className="text-sm font-medium text-foreground">{t.title}</div>
)}
<div className="text-sm text-muted-foreground break-words">{t.message}</div>
{t.actionLabel && t.onClick && (
<div className="text-xs font-medium text-primary mt-1">{t.actionLabel} </div>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); onDismiss(t.id); }}
className="flex-shrink-0 p-1 rounded hover:bg-secondary/80 transition-colors"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
))}
</div>
);
};
export default ToastProvider;

View File

@@ -0,0 +1,285 @@
import {
ChevronDown,
ChevronUp,
Eye,
EyeOff,
LayoutList,
MoreHorizontal,
MoreVertical,
PanelTop,
} from 'lucide-react';
import React, { createContext, useCallback, useContext, useState } from 'react';
import type { ToolbarItemPlacement } from '../../domain/toolbarItemLayout';
import { cn } from '../../lib/utils';
import { Button } from './button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from './context-menu';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
const ToolbarOverflowCloseContext = createContext<(() => void) | null>(null);
/** Close the parent ⋮ overflow menu after a leaf action (no-op outside overflow). */
export function useToolbarOverflowClose(): () => void {
return useContext(ToolbarOverflowCloseContext) ?? (() => {});
}
export type ToolbarCustomizeItem = {
id: string;
label: string;
/** Icon shown before the item label in the customize menu. */
icon?: React.ReactNode;
/** When true, hide option is disabled (locked items). */
locked?: boolean;
/** When false, collapse is not offered (item is show/hide only). Default true. */
supportsCollapse?: boolean;
};
export type ToolbarCustomizeContextMenuProps = {
items: ToolbarCustomizeItem[];
placementOf: (id: string) => ToolbarItemPlacement;
onSetPlacement: (id: string, placement: ToolbarItemPlacement) => unknown;
onMove?: (id: string, direction: 'earlier' | 'later') => void;
onReset: () => void;
t: (key: string, params?: Record<string, unknown>) => string;
children: React.ReactNode;
/** Optional className for the trigger wrapper. */
className?: string;
/** Optional inline style for the trigger wrapper (chrome theming). */
style?: React.CSSProperties;
/** Optional data-section marker for custom CSS / tests. */
dataSection?: string;
/** When false, right-click is disabled (e.g. compact mode). Default true. */
enabled?: boolean;
};
const PLACEMENT_LABEL_KEYS: Record<ToolbarItemPlacement, string> = {
show: 'toolbar.layout.show',
collapse: 'toolbar.layout.collapse',
hide: 'toolbar.layout.hide',
};
const PLACEMENT_ICONS: Record<ToolbarItemPlacement, React.ReactNode> = {
show: <Eye size={12} className="shrink-0 text-muted-foreground" />,
collapse: <LayoutList size={12} className="shrink-0 text-muted-foreground" />,
hide: <EyeOff size={12} className="shrink-0 text-muted-foreground" />,
};
/**
* Right-click the toolbar region to configure each action as show / collapse / hide.
* Optional move earlier/later reorders the full item list.
*/
export const ToolbarCustomizeContextMenu: React.FC<ToolbarCustomizeContextMenuProps> = ({
items,
placementOf,
onSetPlacement,
onMove,
onReset,
t,
children,
className,
style,
dataSection,
enabled = true,
}) => {
if (!enabled || items.length === 0) {
return (
<div className={className} style={style} data-section={dataSection}>
{children}
</div>
);
}
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={className}
style={style}
data-section={dataSection}
data-toolbar-customize-root="true"
>
{children}
</div>
</ContextMenuTrigger>
<ContextMenuContent className="min-w-[12rem]">
<ContextMenuLabel className="flex items-center gap-2">
<PanelTop size={14} className="shrink-0 text-muted-foreground" />
{t('toolbar.layout.customize')}
</ContextMenuLabel>
<ContextMenuSeparator />
{items.map((item, index) => {
const placement = placementOf(item.id);
return (
<ContextMenuSub key={item.id}>
<ContextMenuSubTrigger className="gap-2">
{item.icon ? (
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-muted-foreground [&>svg]:h-3.5 [&>svg]:w-3.5">
{item.icon}
</span>
) : null}
<span className="flex-1 truncate">{item.label}</span>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground shrink-0">
{PLACEMENT_ICONS[placement]}
{t(PLACEMENT_LABEL_KEYS[placement])}
</span>
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[10rem]">
<ContextMenuRadioGroup
value={
item.supportsCollapse === false && placement === 'collapse' ? 'show' : placement
}
onValueChange={(value) => {
if (value === 'show' || value === 'collapse' || value === 'hide') {
if (value === 'collapse' && item.supportsCollapse === false) return;
onSetPlacement(item.id, value);
}
}}
>
<ContextMenuRadioItem value="show" className="gap-2">
<Eye size={12} className="shrink-0" />
{t('toolbar.layout.show')}
</ContextMenuRadioItem>
{item.supportsCollapse !== false && (
<ContextMenuRadioItem value="collapse" className="gap-2">
<LayoutList size={12} className="shrink-0" />
{t('toolbar.layout.collapse')}
</ContextMenuRadioItem>
)}
<ContextMenuRadioItem value="hide" disabled={item.locked} className="gap-2">
<EyeOff size={12} className="shrink-0" />
{t('toolbar.layout.hide')}
</ContextMenuRadioItem>
</ContextMenuRadioGroup>
{onMove && (
<>
<ContextMenuSeparator />
<ContextMenuItem
disabled={index === 0}
onSelect={(e) => {
e.preventDefault();
onMove(item.id, 'earlier');
}}
>
<ChevronUp size={14} className="mr-2" />
{t('toolbar.layout.moveEarlier')}
</ContextMenuItem>
<ContextMenuItem
disabled={index === items.length - 1}
onSelect={(e) => {
e.preventDefault();
onMove(item.id, 'later');
}}
>
<ChevronDown size={14} className="mr-2" />
{t('toolbar.layout.moveLater')}
</ContextMenuItem>
</>
)}
</ContextMenuSubContent>
</ContextMenuSub>
);
})}
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => onReset()}>{t('toolbar.layout.reset')}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export type ToolbarOverflowMenuProps = {
/** When empty, the ⋮ trigger is not rendered. */
hasItems: boolean;
label: string;
children: React.ReactNode;
/** Icon orientation; terminal uses vertical, sftp horizontal. */
orientation?: 'horizontal' | 'vertical';
buttonClassName?: string;
contentClassName?: string;
align?: 'start' | 'center' | 'end';
/** Optional access to the persistent trigger, e.g. for restoring focus after a nested dialog. */
triggerRef?: React.Ref<HTMLButtonElement>;
};
/**
* ⋮ button that opens the collapsed-item region. Hidden when nothing is collapsed.
* Uses controlled Popover so leaf actions can close via useToolbarOverflowClose().
* Nested portaled menus (encoding, bookmark list) use data-toolbar-nested-menu
* to stay open while the nested panel is used.
*/
export const ToolbarOverflowMenu: React.FC<ToolbarOverflowMenuProps> = ({
hasItems,
label,
children,
orientation = 'horizontal',
buttonClassName,
contentClassName,
align = 'end',
triggerRef,
}) => {
const [open, setOpen] = useState(false);
const close = useCallback(() => setOpen(false), []);
if (!hasItems) return null;
const Icon = orientation === 'vertical' ? MoreVertical : MoreHorizontal;
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="ghost"
size="icon"
className={buttonClassName}
aria-label={label}
data-toolbar-overflow-trigger="true"
>
<Icon size={14} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
<PopoverContent
align={align}
className={cn('p-1 w-auto', contentClassName)}
data-toolbar-overflow-menu="true"
onInteractOutside={(e) => {
const target = e.target as Element | null;
if (target?.closest('[data-toolbar-nested-menu="true"]')) {
e.preventDefault();
}
}}
onClick={(e) => {
// Leaf clicks close; nested openers keep the menu for the child panel.
const target = e.target as Element | null;
if (!target) return;
if (target.closest('[data-toolbar-overflow-keep-open="true"]')) return;
if (target.closest('[data-toolbar-nested-menu="true"]')) return;
if (target.closest('button, [role="menuitem"], a')) {
// Defer so the leaf onClick still runs first.
requestAnimationFrame(() => setOpen(false));
}
}}
>
<ToolbarOverflowCloseContext.Provider value={close}>
{children}
</ToolbarOverflowCloseContext.Provider>
</PopoverContent>
</Popover>
);
};

35
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,35 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react"
import { cn } from "../../lib/utils"
import { usePortalContainer } from "./portal-container"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => {
const portalContainer = usePortalContainer()
return (
<TooltipPrimitive.Portal container={portalContainer ?? undefined}>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-[999999] overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
)
})
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildItemIndexToVisualIndexMap,
clampListIndex,
clampScrollTop,
getFixedSizeVirtualWindow,
stepListIndex,
} from './virtualListMath.ts';
test('clampScrollTop keeps the window on-content after a deep scroll + filter shrink', () => {
// User was deep in a long list (scrollTop large), then filter leaves few rows.
assert.equal(clampScrollTop(50_000, 10 * 44, 360), Math.max(0, 10 * 44 - 360));
assert.equal(clampScrollTop(-10, 1000, 200), 0);
assert.equal(clampScrollTop(100, 1000, 200), 100);
assert.equal(clampScrollTop(0, 0, 200), 0);
});
test('getFixedSizeVirtualWindow never starts past the last item after shrink', () => {
const deep = getFixedSizeVirtualWindow({
itemCount: 10_000,
itemHeight: 44,
scrollTop: 40_000,
viewportHeight: 300,
overscan: 6,
});
assert.ok(deep.startIndex < 10_000);
assert.ok(deep.endIndex - deep.startIndex < 100);
assert.ok(deep.endIndex <= 10_000);
const shrunk = getFixedSizeVirtualWindow({
itemCount: 8,
itemHeight: 44,
scrollTop: 40_000,
viewportHeight: 300,
overscan: 6,
});
assert.equal(shrunk.effectiveScrollTop, Math.max(0, 8 * 44 - 300));
assert.ok(shrunk.startIndex >= 0);
assert.ok(shrunk.startIndex < 8);
assert.equal(shrunk.endIndex, 8);
// Window must include at least one real item so the list never blanks.
assert.ok(shrunk.endIndex > shrunk.startIndex);
});
test('getFixedSizeVirtualWindow only materializes a viewport-sized slice for large lists', () => {
const window = getFixedSizeVirtualWindow({
itemCount: 8_000,
itemHeight: 44,
scrollTop: 0,
viewportHeight: 360,
overscan: 8,
});
const rendered = window.endIndex - window.startIndex;
assert.ok(rendered > 0);
assert.ok(rendered < 100, `expected viewport window, got ${rendered}`);
assert.equal(window.startIndex, 0);
});
test('clampListIndex and stepListIndex never produce -1 on empty or non-empty lists', () => {
assert.equal(clampListIndex(-1, 0), 0);
assert.equal(clampListIndex(5, 0), 0);
assert.equal(clampListIndex(-1, 5), 0);
assert.equal(clampListIndex(99, 5), 4);
assert.equal(clampListIndex(2, 5), 2);
assert.equal(stepListIndex(0, 0, 1), 0);
assert.equal(stepListIndex(0, 0, -1), 0);
// Empty list + ArrowDown must not go to -1 (QuickSwitcher regression).
assert.equal(stepListIndex(0, 0, 1), 0);
assert.equal(stepListIndex(0, 3, 1), 1);
assert.equal(stepListIndex(2, 3, 1), 2);
assert.equal(stepListIndex(0, 3, -1), 0);
});
test('buildItemIndexToVisualIndexMap skips headers for keyboard scroll targets', () => {
const visual = [
{ kind: 'header' },
{ kind: 'item' },
{ kind: 'item' },
{ kind: 'header' },
{ kind: 'item' },
];
const map = buildItemIndexToVisualIndexMap(visual);
assert.equal(map.get(0), 1);
assert.equal(map.get(1), 2);
assert.equal(map.get(2), 4);
assert.equal(map.size, 3);
// Keyboard index 1 scrolls to visual row 2 (second host, after first header).
assert.notEqual(map.get(1), 1);
});

View File

@@ -0,0 +1,68 @@
/**
* Pure math for virtual lists and keyboard cursors.
* Kept free of React so tests drive the same functions the pickers use.
*/
export function clampScrollTop(
scrollTop: number,
totalHeight: number,
viewportHeight: number,
): number {
const maxScroll = Math.max(0, totalHeight - Math.max(viewportHeight, 0));
return Math.min(Math.max(0, scrollTop), maxScroll);
}
export function getFixedSizeVirtualWindow({
itemCount,
itemHeight,
scrollTop,
viewportHeight,
overscan,
}: {
itemCount: number;
itemHeight: number;
scrollTop: number;
viewportHeight: number;
overscan: number;
}): { startIndex: number; endIndex: number; effectiveScrollTop: number; totalHeight: number } {
const totalHeight = Math.max(0, itemCount * itemHeight);
const effectiveScrollTop = clampScrollTop(scrollTop, totalHeight, viewportHeight);
if (itemCount <= 0 || itemHeight <= 0) {
return { startIndex: 0, endIndex: 0, effectiveScrollTop, totalHeight };
}
const startIndex = Math.max(0, Math.floor(effectiveScrollTop / itemHeight) - overscan);
const visibleCount = Math.ceil(Math.max(viewportHeight, 1) / itemHeight) + overscan * 2;
const endIndex = Math.min(itemCount, startIndex + visibleCount);
return { startIndex, endIndex, effectiveScrollTop, totalHeight };
}
/** Clamp a keyboard cursor into [0, length-1], or 0 when the list is empty. */
export function clampListIndex(index: number, length: number): number {
if (length <= 0) return 0;
if (!Number.isFinite(index)) return 0;
return Math.max(0, Math.min(Math.trunc(index), length - 1));
}
/** Move a keyboard cursor by delta without ever landing on -1. */
export function stepListIndex(index: number, length: number, delta: number): number {
if (length <= 0) return 0;
return clampListIndex(index + delta, length);
}
/**
* Map selectable item indices to visual row indices for lists that interleave
* non-selectable chrome (headers, hints, empty rows).
*/
export function buildItemIndexToVisualIndexMap(
visualRows: ReadonlyArray<{ kind: string }>,
itemKind = 'item',
): Map<number, number> {
const map = new Map<number, number>();
let itemIndex = 0;
visualRows.forEach((row, visualIndex) => {
if (row.kind !== itemKind) return;
map.set(itemIndex, visualIndex);
itemIndex += 1;
});
return map;
}