mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 18:36:23 +02:00
feat(crypto): add SurfSense 2.0 Crypto Co-Pilot UI components
Frontend - Web Dashboard: - Add crypto dashboard page with Watchlist, Alerts, Market, Profile tabs - Add 11 tool-ui components for inline chat display - Add crypto components (ChainIcon, SafetyBadge, PriceDisplay, etc.) - Add modals (AddTokenModal, CreateAlertModal) - Add mock data for development Frontend - Browser Extension: - Add shared components (ChainIcon, RiskBadge, PriceDisplay, SuggestionCard) - Add crypto components (SafetyScoreDisplay, WatchlistPanel, AlertConfigModal) - Add chat enhancements (WelcomeScreen, ThinkingStepsDisplay) - Add widget components for inline display - Enhance TokenInfoCard, ChatHeader, ChatInput, ChatInterface Documentation: - Add conversational UX specification - Add UX analysis report - Update extension UX design This implements the Conversational UX paradigm where crypto features are AI-callable tools that render inline in the chat interface.
This commit is contained in:
parent
ad795eb830
commit
e4d020799b
58 changed files with 11315 additions and 661 deletions
|
|
@ -1,42 +1,256 @@
|
|||
import { useState } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { useState, useRef } from "react";
|
||||
import { Send, Paperclip, X, FileText, Image, File } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export interface AttachedFile {
|
||||
/** File ID */
|
||||
id: string;
|
||||
/** File name */
|
||||
name: string;
|
||||
/** File type */
|
||||
type: string;
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
/** File object */
|
||||
file: File;
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
/** Callback when message is sent */
|
||||
onSend: (content: string, attachments?: AttachedFile[]) => void;
|
||||
/** Whether input is disabled */
|
||||
disabled?: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Whether to show attachment button */
|
||||
showAttachment?: boolean;
|
||||
/** Accepted file types */
|
||||
acceptedFileTypes?: string;
|
||||
/** Max file size in bytes (default 10MB) */
|
||||
maxFileSize?: number;
|
||||
/** Quick action suggestions */
|
||||
suggestions?: string[];
|
||||
/** Callback when suggestion is clicked */
|
||||
onSuggestionClick?: (suggestion: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat input component with send button
|
||||
* Enhanced chat input with attachment support and suggestions
|
||||
*
|
||||
* Features:
|
||||
* - Text input with send button
|
||||
* - File attachment button
|
||||
* - Attached files preview
|
||||
* - Quick action suggestions
|
||||
* - Keyboard shortcuts (Enter to send)
|
||||
*/
|
||||
export function ChatInput({ onSend, disabled, placeholder }: ChatInputProps) {
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
disabled,
|
||||
placeholder,
|
||||
showAttachment = true,
|
||||
acceptedFileTypes = ".pdf,.txt,.md,.json,.csv,image/*",
|
||||
maxFileSize = 10 * 1024 * 1024, // 10MB
|
||||
suggestions = [],
|
||||
onSuggestionClick,
|
||||
}: ChatInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (input.trim() && !disabled) {
|
||||
onSend(input.trim());
|
||||
if ((input.trim() || attachments.length > 0) && !disabled) {
|
||||
onSend(input.trim(), attachments.length > 0 ? attachments : undefined);
|
||||
setInput("");
|
||||
setAttachments([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
if (!files) return;
|
||||
|
||||
const newAttachments: AttachedFile[] = [];
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
// Check file size
|
||||
if (file.size > maxFileSize) {
|
||||
console.warn(`File ${file.name} exceeds max size of ${maxFileSize / 1024 / 1024}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
newAttachments.push({
|
||||
id: `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
file,
|
||||
});
|
||||
});
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
};
|
||||
|
||||
const handleRemoveAttachment = (id: string) => {
|
||||
setAttachments(prev => prev.filter(a => a.id !== id));
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
handleFileSelect(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.startsWith("image/")) return Image;
|
||||
if (type.includes("pdf") || type.includes("text")) return FileText;
|
||||
return File;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="border-t p-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={placeholder || "Type a message..."}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-3 py-2 border rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<Button type="submit" size="icon" disabled={disabled || !input.trim()}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="border-t">
|
||||
{/* Quick suggestions */}
|
||||
{suggestions.length > 0 && input.length === 0 && attachments.length === 0 && (
|
||||
<div className="px-3 pt-2 flex gap-2 flex-wrap">
|
||||
{suggestions.slice(0, 3).map((suggestion, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="text-xs px-2 py-1 rounded-full bg-muted hover:bg-muted/80 text-muted-foreground transition-colors"
|
||||
onClick={() => onSuggestionClick?.(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attached files preview */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="px-3 pt-2 flex gap-2 flex-wrap">
|
||||
{attachments.map((attachment) => {
|
||||
const FileIcon = getFileIcon(attachment.type);
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted text-sm group"
|
||||
>
|
||||
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="max-w-[100px] truncate">{attachment.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({formatFileSize(attachment.size)})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemoveAttachment(attachment.id)}
|
||||
className="ml-1 p-0.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input form */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={cn(
|
||||
"p-3 transition-colors",
|
||||
dragOver && "bg-primary/5"
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Attachment button */}
|
||||
{showAttachment && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={acceptedFileTypes}
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
title="Attach files"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Text input */}
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder || "Type a message..."}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"w-full px-3 py-2 border rounded-md bg-background text-sm",
|
||||
"focus:outline-none focus:ring-2 focus:ring-primary",
|
||||
"resize-none min-h-[38px] max-h-[120px]",
|
||||
"scrollbar-thin scrollbar-thumb-muted"
|
||||
)}
|
||||
style={{
|
||||
height: "auto",
|
||||
minHeight: "38px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Send button */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="h-9 w-9 flex-shrink-0"
|
||||
disabled={disabled || (!input.trim() && attachments.length === 0)}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Drag hint */}
|
||||
{dragOver && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 rounded-md border-2 border-dashed border-primary pointer-events-none">
|
||||
<p className="text-sm text-primary font-medium">Drop files here</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue