mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-30 19:06:23 +02:00
Mega UI revamp
This commit is contained in:
parent
650f481a96
commit
bcb686a20d
94 changed files with 6984 additions and 3889 deletions
145
apps/rowboat/components/common/compose-box.tsx
Normal file
145
apps/rowboat/components/common/compose-box.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
'use client';
|
||||
|
||||
import { Button, Spinner } from "@heroui/react";
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
// Add a type to support both message formats
|
||||
type FlexibleMessage = {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string | any;
|
||||
version?: string;
|
||||
chatId?: string;
|
||||
createdAt?: string;
|
||||
// Add any other optional fields that might be needed
|
||||
};
|
||||
|
||||
export function ComposeBox({
|
||||
minRows=3,
|
||||
disabled=false,
|
||||
loading=false,
|
||||
handleUserMessage,
|
||||
messages,
|
||||
}: {
|
||||
minRows?: number;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
handleUserMessage: (prompt: string) => void;
|
||||
messages: FlexibleMessage[]; // Use the flexible message type
|
||||
}) {
|
||||
const [input, setInput] = useState('');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
function handleInput() {
|
||||
const prompt = input.trim();
|
||||
if (!prompt) {
|
||||
return;
|
||||
}
|
||||
setInput('');
|
||||
|
||||
handleUserMessage(prompt);
|
||||
}
|
||||
|
||||
function handleInputKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleInput();
|
||||
}
|
||||
}
|
||||
// focus on the input field
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
{/* Keyboard shortcut hint */}
|
||||
<div className="absolute -top-6 right-0 text-xs text-gray-500 dark:text-gray-400 opacity-0
|
||||
group-hover:opacity-100 transition-opacity">
|
||||
Press ⌘ + Enter to send
|
||||
</div>
|
||||
|
||||
{/* Outer container with padding */}
|
||||
<div className="rounded-2xl border-[1.5px] border-gray-200 dark:border-[#2a2d31] p-3 relative
|
||||
bg-white dark:bg-[#1e2023] flex items-end gap-2">
|
||||
{/* Textarea */}
|
||||
<div className="flex-1">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
disabled={disabled || loading}
|
||||
placeholder="Type a message..."
|
||||
autoResize={true}
|
||||
maxHeight={120}
|
||||
className={`
|
||||
!min-h-0
|
||||
!border-0 !shadow-none !ring-0
|
||||
bg-transparent
|
||||
resize-none
|
||||
overflow-y-auto
|
||||
[&::-webkit-scrollbar]:w-1
|
||||
[&::-webkit-scrollbar-track]:bg-transparent
|
||||
[&::-webkit-scrollbar-thumb]:bg-gray-300
|
||||
[&::-webkit-scrollbar-thumb]:dark:bg-[#2a2d31]
|
||||
[&::-webkit-scrollbar-thumb]:rounded-full
|
||||
placeholder:text-gray-500 dark:placeholder:text-gray-400
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Send button */}
|
||||
<Button
|
||||
size="sm"
|
||||
isIconOnly
|
||||
disabled={disabled || loading || !input.trim()}
|
||||
onPress={handleInput}
|
||||
className={`
|
||||
transition-all duration-200
|
||||
${input.trim()
|
||||
? 'bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:hover:bg-indigo-800/60 dark:text-indigo-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500'
|
||||
}
|
||||
scale-100 hover:scale-105 active:scale-95
|
||||
disabled:opacity-50 disabled:scale-95
|
||||
hover:shadow-md dark:hover:shadow-indigo-950/10
|
||||
mb-0.5
|
||||
`}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner size="sm" color={input.trim() ? "primary" : "default"} />
|
||||
) : (
|
||||
<SendIcon
|
||||
size={16}
|
||||
className={`transform transition-transform ${isFocused ? 'translate-x-0.5' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom SendIcon component for better visual alignment
|
||||
function SendIcon({ size, className }: { size: number, className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M22 2L11 13" />
|
||||
<path d="M22 2L15 22L11 13L2 9L22 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
11
apps/rowboat/components/common/copy-as-json-button.tsx
Normal file
11
apps/rowboat/components/common/copy-as-json-button.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { CopyButton } from "@/components/common/copy-button";
|
||||
|
||||
export function CopyAsJsonButton({ onCopy }: { onCopy: () => void }) {
|
||||
return <div className="absolute top-0 right-0">
|
||||
<CopyButton
|
||||
onCopy={onCopy}
|
||||
label="Copy as JSON"
|
||||
successLabel="Copied"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
41
apps/rowboat/components/common/copy-button.tsx
Normal file
41
apps/rowboat/components/common/copy-button.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
'use client';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyIcon, CheckIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function CopyButton({
|
||||
onCopy,
|
||||
label,
|
||||
successLabel,
|
||||
}: {
|
||||
onCopy: () => void;
|
||||
label: string;
|
||||
successLabel: string;
|
||||
}) {
|
||||
const [showCopySuccess, setShowCopySuccess] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
onCopy();
|
||||
setShowCopySuccess(true);
|
||||
setTimeout(() => {
|
||||
setShowCopySuccess(false);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="gap-2"
|
||||
showHoverContent
|
||||
hoverContent={showCopySuccess ? successLabel : label}
|
||||
>
|
||||
{showCopySuccess ? (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
93
apps/rowboat/components/common/panel-common.tsx
Normal file
93
apps/rowboat/components/common/panel-common.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import clsx from "clsx";
|
||||
|
||||
export function ActionButton({
|
||||
icon = null,
|
||||
children,
|
||||
onClick = undefined,
|
||||
disabled = false,
|
||||
primary = false,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void | undefined;
|
||||
disabled?: boolean;
|
||||
primary?: boolean;
|
||||
}) {
|
||||
const onClickProp = onClick ? { onClick } : {};
|
||||
return <button
|
||||
disabled={disabled}
|
||||
className={clsx("rounded-md text-xs flex items-center gap-1 disabled:text-gray-300 dark:disabled:text-gray-600 hover:text-gray-600 dark:hover:text-gray-300", {
|
||||
"text-blue-600 dark:text-blue-400": primary,
|
||||
"text-gray-400 dark:text-gray-500": !primary,
|
||||
})}
|
||||
{...onClickProp}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
</button>;
|
||||
}
|
||||
|
||||
interface PanelProps {
|
||||
title: React.ReactNode;
|
||||
rightActions?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
maxHeight?: string;
|
||||
variant?: 'default' | 'copilot' | 'projects';
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
rightActions,
|
||||
actions,
|
||||
children,
|
||||
maxHeight,
|
||||
variant = 'default',
|
||||
}: PanelProps) {
|
||||
return <div className={clsx(
|
||||
"flex flex-col overflow-hidden rounded-xl border",
|
||||
"border-zinc-200 dark:border-zinc-800",
|
||||
"bg-white dark:bg-zinc-900",
|
||||
maxHeight ? "max-h-[var(--panel-height)]" : "h-full"
|
||||
)}
|
||||
style={{ '--panel-height': maxHeight } as React.CSSProperties}
|
||||
>
|
||||
<div className={clsx(
|
||||
"shrink-0 border-b border-zinc-100 dark:border-zinc-800",
|
||||
variant === 'projects' ? "flex flex-col gap-3 px-4 py-3" : "flex items-center justify-between px-4 py-3"
|
||||
)}>
|
||||
{variant === 'projects' ? (
|
||||
<>
|
||||
<div className="text-sm uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
|
||||
{title}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">
|
||||
{actions}
|
||||
</div>}
|
||||
</>
|
||||
) : variant === 'copilot' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{title}
|
||||
</div>
|
||||
{rightActions}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{title}
|
||||
{rightActions}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"min-h-0 flex-1 overflow-y-auto",
|
||||
variant === 'projects' && "custom-scrollbar"
|
||||
)}>
|
||||
{variant === 'projects' ? (
|
||||
<div className="px-3 py-2 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
) : children}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
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