mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-27 09:26:23 +02:00
Mega UI revamp
This commit is contained in:
parent
650f481a96
commit
bcb686a20d
94 changed files with 6984 additions and 3889 deletions
77
apps/rowboat/components/ui/button.tsx
Normal file
77
apps/rowboat/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import clsx from 'clsx';
|
||||
import { ButtonHTMLAttributes, forwardRef } from "react";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'tertiary';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
startContent?: React.ReactNode;
|
||||
endContent?: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
hoverContent?: React.ReactNode;
|
||||
showHoverContent?: boolean;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(({
|
||||
className,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
startContent,
|
||||
endContent,
|
||||
isLoading,
|
||||
children,
|
||||
disabled,
|
||||
hoverContent,
|
||||
showHoverContent = false,
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={isLoading || disabled}
|
||||
className={clsx(
|
||||
"inline-flex items-center justify-center rounded-full font-medium transition-all",
|
||||
"focus-visible:outline-none transform hover:scale-[1.02] hover:shadow-md",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
'primary': "text-sm px-3 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-950 dark:hover:bg-indigo-900 dark:text-indigo-400",
|
||||
'secondary': "bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-100",
|
||||
'tertiary': "bg-transparent hover:bg-gray-100 text-gray-700 dark:hover:bg-gray-800 dark:text-gray-300",
|
||||
}[variant],
|
||||
{
|
||||
'sm': "min-h-[2rem] px-3 text-sm py-1",
|
||||
'md': "min-h-[2.5rem] px-4 py-1",
|
||||
'lg': "min-h-[3rem] px-4 py-2 text-sm",
|
||||
}[size],
|
||||
"group",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && (
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
)}
|
||||
{startContent && (
|
||||
<span className={clsx(
|
||||
"shrink-0",
|
||||
children || hoverContent ? "mr-2" : ""
|
||||
)}>
|
||||
{startContent}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">
|
||||
{showHoverContent ? (
|
||||
<>
|
||||
<span className="group-hover:hidden">{children}</span>
|
||||
<span className="hidden group-hover:inline">{hoverContent}</span>
|
||||
</>
|
||||
) : children}
|
||||
</span>
|
||||
{endContent && <span className="ml-2 shrink-0">{endContent}</span>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = "Button";
|
||||
49
apps/rowboat/components/ui/dropdown.tsx
Normal file
49
apps/rowboat/components/ui/dropdown.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { Select, SelectItem, SelectProps } from "@heroui/react";
|
||||
import { ReactNode, ChangeEvent } from "react";
|
||||
|
||||
export interface DropdownOption {
|
||||
key: string;
|
||||
label: string;
|
||||
startContent?: ReactNode;
|
||||
endContent?: ReactNode;
|
||||
}
|
||||
|
||||
interface DropdownProps extends Omit<SelectProps, 'children' | 'onChange'> {
|
||||
options: DropdownOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
width?: string | number;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
export function Dropdown({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className = "",
|
||||
width = "100%",
|
||||
containerClassName = "",
|
||||
...selectProps
|
||||
}: DropdownProps) {
|
||||
return (
|
||||
<div className={`${containerClassName}`} style={{ width }}>
|
||||
<Select
|
||||
{...selectProps}
|
||||
selectedKeys={[value]}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange(e.target.value)}
|
||||
className={`${className}`}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.key}
|
||||
startContent={option.startContent}
|
||||
endContent={option.endContent}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
apps/rowboat/components/ui/horizontal-divider.tsx
Normal file
14
apps/rowboat/components/ui/horizontal-divider.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
interface HorizontalDividerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HorizontalDivider({ className }: HorizontalDividerProps) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
"border-t border-gray-200 dark:border-gray-700",
|
||||
className
|
||||
)} />
|
||||
);
|
||||
}
|
||||
42
apps/rowboat/components/ui/input.tsx
Normal file
42
apps/rowboat/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { clsx } from "clsx";
|
||||
import { InputHTMLAttributes, forwardRef } from "react";
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
className,
|
||||
error,
|
||||
label,
|
||||
...props
|
||||
}, ref) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
"flex h-10 w-full rounded-md border border-gray-300 bg-white px-3 py-2",
|
||||
"text-sm placeholder:text-gray-400",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100",
|
||||
error && "border-red-500 focus-visible:ring-red-500",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = "Input";
|
||||
32
apps/rowboat/components/ui/page-heading.tsx
Normal file
32
apps/rowboat/components/ui/page-heading.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import clsx from 'clsx';
|
||||
import { tokens } from "@/app/styles/design-tokens";
|
||||
|
||||
interface PageHeadingProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function PageHeading({ title, description }: PageHeadingProps) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className={clsx(
|
||||
tokens.typography.weights.semibold,
|
||||
tokens.typography.sizes["2xl"],
|
||||
tokens.colors.light.text.primary,
|
||||
tokens.colors.dark.text.primary
|
||||
)}>
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className={clsx(
|
||||
"mt-2",
|
||||
tokens.typography.sizes.base,
|
||||
tokens.colors.light.text.secondary,
|
||||
tokens.colors.dark.text.secondary
|
||||
)}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,14 +3,14 @@
|
|||
import { GripVertical } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
import clsx from 'clsx';
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
className={clsx(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
|
|
@ -28,7 +28,7 @@ const ResizableHandle = ({
|
|||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
className={clsx(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
|
|
|
|||
44
apps/rowboat/components/ui/search-bar.tsx
Normal file
44
apps/rowboat/components/ui/search-bar.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use client';
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchIcon, XIcon } from "lucide-react";
|
||||
import { InputHTMLAttributes } from "react";
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface SearchBarProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
className,
|
||||
...props
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<SearchIcon
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={clsx("pl-9 pr-8 bg-transparent", className)}
|
||||
{...props}
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<XIcon size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
apps/rowboat/components/ui/section-heading.tsx
Normal file
32
apps/rowboat/components/ui/section-heading.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import clsx from 'clsx';
|
||||
import { tokens } from "@/app/styles/design-tokens";
|
||||
|
||||
interface SectionHeadingProps {
|
||||
children: React.ReactNode;
|
||||
subheading?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SectionHeading({ children, subheading }: SectionHeadingProps) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className={clsx(
|
||||
tokens.typography.weights.medium,
|
||||
tokens.typography.sizes.lg,
|
||||
tokens.colors.light.text.primary,
|
||||
tokens.colors.dark.text.primary
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
{subheading && (
|
||||
<p className={clsx(
|
||||
tokens.typography.sizes.sm,
|
||||
tokens.typography.weights.normal,
|
||||
tokens.colors.light.text.secondary,
|
||||
tokens.colors.dark.text.secondary
|
||||
)}>
|
||||
{subheading}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
apps/rowboat/components/ui/textarea.tsx
Normal file
151
apps/rowboat/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import clsx from 'clsx';
|
||||
import { TextareaHTMLAttributes, forwardRef, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
autoResize?: boolean;
|
||||
maxHeight?: number;
|
||||
useValidation?: boolean;
|
||||
validate?: (value: string) => { valid: boolean; errorMessage?: string };
|
||||
onValidatedChange?: (value: string) => void;
|
||||
updateOnBlur?: boolean;
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
|
||||
className,
|
||||
label,
|
||||
autoResize = false,
|
||||
maxHeight = 120, // default max height (roughly 5 lines)
|
||||
value: propValue,
|
||||
onChange,
|
||||
// New validation props
|
||||
useValidation = false,
|
||||
validate,
|
||||
onValidatedChange,
|
||||
updateOnBlur = false,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
...props
|
||||
}, ref) => {
|
||||
const internalRef = useRef<HTMLTextAreaElement>(null);
|
||||
const textareaRef = (ref as any) || internalRef;
|
||||
|
||||
// Local state for validation mode
|
||||
const [localValue, setLocalValue] = useState(propValue as string);
|
||||
const [validationError, setValidationError] = useState<string | undefined>();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Sync local state with prop value when not editing
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setLocalValue(propValue as string);
|
||||
}
|
||||
}, [propValue, isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoResize) return;
|
||||
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const adjustHeight = () => {
|
||||
textarea.style.height = 'auto';
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
|
||||
|
||||
// Add scrolling if content exceeds maxHeight
|
||||
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||
};
|
||||
|
||||
adjustHeight();
|
||||
|
||||
// Add window resize listener
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
return () => window.removeEventListener('resize', adjustHeight);
|
||||
}, [localValue, autoResize, maxHeight, textareaRef]);
|
||||
|
||||
const validateAndUpdate = (value: string) => {
|
||||
if (validate) {
|
||||
const result = validate(value);
|
||||
setValidationError(result.errorMessage);
|
||||
if (result.valid && onValidatedChange) {
|
||||
onValidatedChange(value);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} else if (onValidatedChange) {
|
||||
onValidatedChange(value);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValue(newValue);
|
||||
setIsEditing(true);
|
||||
|
||||
if (!updateOnBlur) {
|
||||
if (useValidation) {
|
||||
validateAndUpdate(newValue);
|
||||
} else {
|
||||
onChange?.(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
setIsEditing(false);
|
||||
if (updateOnBlur) {
|
||||
if (useValidation) {
|
||||
validateAndUpdate(localValue);
|
||||
} else {
|
||||
const syntheticEvent = {
|
||||
...e,
|
||||
target: { ...e.target, value: localValue },
|
||||
currentTarget: { ...e.currentTarget, value: localValue }
|
||||
};
|
||||
onChange?.(syntheticEvent as any);
|
||||
}
|
||||
}
|
||||
onBlur?.(e);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (updateOnBlur && e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
textareaRef.current?.blur();
|
||||
}
|
||||
onKeyDown?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={localValue}
|
||||
className={clsx(
|
||||
"flex w-full text-sm focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"transition-colors",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
...props.style,
|
||||
minHeight: autoResize ? '24px' : undefined,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
Loading…
Add table
Add a link
Reference in a new issue