2026-01-13 18:47:57 +00:00
|
|
|
import React, {
|
|
|
|
|
useState,
|
|
|
|
|
useCallback,
|
|
|
|
|
useRef,
|
|
|
|
|
useEffect,
|
|
|
|
|
lazy,
|
|
|
|
|
Suspense,
|
|
|
|
|
} from 'react';
|
|
|
|
|
import {
|
|
|
|
|
Send,
|
|
|
|
|
Smile,
|
|
|
|
|
Paperclip,
|
|
|
|
|
X,
|
|
|
|
|
Image as ImageIcon,
|
|
|
|
|
File,
|
|
|
|
|
Mic,
|
|
|
|
|
} from 'lucide-react';
|
2025-12-03 21:56:50 +00:00
|
|
|
import { useChat } from '../hooks/useChat';
|
|
|
|
|
import { useChatStore } from '../store/chatStore';
|
2026-01-07 09:32:53 +00:00
|
|
|
import { Theme } from 'emoji-picker-react';
|
2026-01-04 00:41:51 +00:00
|
|
|
import { useDropzone } from 'react-dropzone';
|
|
|
|
|
import { apiClient } from '@/services/api/client';
|
|
|
|
|
import { MessageAttachment } from '../types';
|
2026-01-07 09:32:53 +00:00
|
|
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
|
|
|
|
import { logger } from '@/utils/logger';
|
2026-01-11 02:20:52 +00:00
|
|
|
import { cn } from '@/lib/utils';
|
2026-01-25 11:33:46 +00:00
|
|
|
import { Button } from '@/components/ui/button';
|
2026-01-15 19:01:47 +00:00
|
|
|
import { useIsRateLimited } from '@/hooks/useIsRateLimited';
|
2026-01-11 02:20:52 +00:00
|
|
|
|
|
|
|
|
// Lazy load
|
2026-01-13 18:47:57 +00:00
|
|
|
const EmojiPicker = lazy(() =>
|
|
|
|
|
import('emoji-picker-react').then((module) => ({ default: module.default })),
|
|
|
|
|
);
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
export const ChatInput: React.FC = () => {
|
|
|
|
|
const [message, setMessage] = useState('');
|
2026-01-04 00:41:51 +00:00
|
|
|
const [attachments, setAttachments] = useState<MessageAttachment[]>([]);
|
|
|
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
|
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
|
|
|
|
const { sendMessage, setTyping } = useChat();
|
2025-12-03 21:56:50 +00:00
|
|
|
const { currentConversationId } = useChatStore();
|
2026-01-15 19:01:47 +00:00
|
|
|
const isRateLimited = useIsRateLimited();
|
2026-01-04 00:41:51 +00:00
|
|
|
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
2026-01-04 00:41:51 +00:00
|
|
|
if ((message.trim() || attachments.length > 0) && currentConversationId) {
|
|
|
|
|
sendMessage(message, attachments.length > 0 ? attachments : undefined);
|
2025-12-03 21:56:50 +00:00
|
|
|
setMessage('');
|
2026-01-04 00:41:51 +00:00
|
|
|
setAttachments([]);
|
|
|
|
|
|
2026-01-11 02:20:52 +00:00
|
|
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
2026-01-04 00:41:51 +00:00
|
|
|
setTyping(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
|
|
|
|
setIsUploading(true);
|
|
|
|
|
try {
|
|
|
|
|
const uploadPromises = acceptedFiles.map(async (file) => {
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('file', file);
|
|
|
|
|
const response = await apiClient.post('/uploads', formData, {
|
2026-01-13 18:47:57 +00:00
|
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
2026-01-04 00:41:51 +00:00
|
|
|
});
|
|
|
|
|
const data = response.data;
|
|
|
|
|
return {
|
|
|
|
|
file_name: file.name,
|
|
|
|
|
file_type: file.type,
|
2026-01-11 02:20:52 +00:00
|
|
|
file_url: data.url,
|
2026-01-04 00:41:51 +00:00
|
|
|
file_size: file.size,
|
|
|
|
|
} as MessageAttachment;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const newAttachments = await Promise.all(uploadPromises);
|
|
|
|
|
setAttachments((prev) => [...prev, ...newAttachments]);
|
|
|
|
|
} catch (error) {
|
2026-01-07 09:32:53 +00:00
|
|
|
logger.error('Failed to upload files', {
|
2026-01-13 18:47:57 +00:00
|
|
|
error: error instanceof Error ? error.message : String(error),
|
2026-01-07 09:32:53 +00:00
|
|
|
});
|
2026-01-04 00:41:51 +00:00
|
|
|
} finally {
|
|
|
|
|
setIsUploading(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
|
|
|
onDrop,
|
2026-01-11 02:20:52 +00:00
|
|
|
noClick: true,
|
2026-01-04 00:41:51 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleEmojiClick = (emojiData: { emoji: string }) => {
|
|
|
|
|
setMessage((prev) => prev + emojiData.emoji);
|
|
|
|
|
setShowEmojiPicker(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeAttachment = (index: number) => {
|
|
|
|
|
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (message.length > 0) {
|
|
|
|
|
setTyping(true);
|
2026-01-11 02:20:52 +00:00
|
|
|
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
2026-01-04 00:41:51 +00:00
|
|
|
typingTimeoutRef.current = setTimeout(() => {
|
|
|
|
|
setTyping(false);
|
2026-01-11 02:20:52 +00:00
|
|
|
}, 3000);
|
2026-01-04 00:41:51 +00:00
|
|
|
} else {
|
|
|
|
|
setTyping(false);
|
|
|
|
|
}
|
|
|
|
|
}, [message, setTyping]);
|
|
|
|
|
|
2025-12-03 21:56:50 +00:00
|
|
|
return (
|
2026-01-11 02:20:52 +00:00
|
|
|
<div {...getRootProps()} className="relative">
|
|
|
|
|
<input {...getInputProps()} ref={fileInputRef} className="hidden" />
|
|
|
|
|
|
|
|
|
|
{/* Upload Overlay */}
|
|
|
|
|
{isDragActive && (
|
2026-02-08 23:08:42 +00:00
|
|
|
<div className="absolute bottom-full left-0 right-0 h-48 z-50 bg-muted/10 backdrop-blur-md flex items-center justify-center border-t-2 border-border border-dashed rounded-t-2xl animate-fadeIn">
|
2026-01-11 02:20:52 +00:00
|
|
|
<div className="text-center">
|
2026-02-08 23:08:42 +00:00
|
|
|
<div className="w-12 h-12 rounded-full bg-muted/20 flex items-center justify-center mx-auto mb-2 animate-bounce">
|
2026-01-16 10:40:13 +00:00
|
|
|
<Paperclip className="w-6 h-6 text-kodo-steel" />
|
2026-01-11 02:20:52 +00:00
|
|
|
</div>
|
2026-01-16 10:40:13 +00:00
|
|
|
<p className="text-kodo-steel font-mono uppercase tracking-widest text-sm">
|
2026-01-13 18:47:57 +00:00
|
|
|
Initiate Data Transfer
|
|
|
|
|
</p>
|
2026-01-11 02:20:52 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Attachments Preview */}
|
2026-01-04 00:41:51 +00:00
|
|
|
{attachments.length > 0 && (
|
aesthetic-improvements: align spacing to 8px grid (Action 11.2.1.3)
- Created automated script (scripts/align-8px-grid.py) to align all spacing to 8px grid
- Replaced non-8px-aligned spacing: gap-3/p-3/m-3 (12px) → gap-4/p-4/m-4 (16px), gap-5/p-5/m-5 (20px) → gap-6/p-6/m-6 (24px), gap-10/p-10/m-10 (40px) → gap-12/p-12/m-12 (48px), gap-20/p-20/m-20 (80px) → gap-24/p-24/m-24 (96px)
- Preserved: 4px values (gap-1, p-1, m-1) as they may be intentional fine-tuning, responsive breakpoints (sm:, md:, lg:), test files, documentation
- Modified files across all components to ensure consistent 8px grid alignment
- Action 11.2.1.3: Align all elements to 8px grid - COMPLETE
2026-01-16 10:50:46 +00:00
|
|
|
<div className="absolute bottom-full left-0 right-0 p-4 bg-kodo-void/90 backdrop-blur-xl border-t border-white/10 flex gap-2 overflow-x-auto">
|
2026-01-04 00:41:51 +00:00
|
|
|
{attachments.map((att, i) => (
|
2026-01-13 18:47:57 +00:00
|
|
|
<div
|
|
|
|
|
key={i}
|
2026-02-10 13:06:30 +00:00
|
|
|
className="relative group flex items-center gap-2 p-2 bg-white/5 rounded-lg border border-white/10 text-xs text-white min-w-36"
|
2026-01-13 18:47:57 +00:00
|
|
|
>
|
2026-01-04 00:41:51 +00:00
|
|
|
{att.file_type.startsWith('image') ? (
|
ui(tokens): migrate kodo-cyan to primary (51 files, 88 instances)
Replace legacy text-kodo-cyan/border-kodo-cyan/bg-kodo-cyan with semantic
text-primary/border-primary/bg-primary across 51 components.
The brand primary color now uses the design system token, enabling proper
theme adaptation. Covers UI primitives, search, dashboard, chat, playlists,
settings, social, marketplace, and auth components.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 23:19:12 +00:00
|
|
|
<ImageIcon size={14} className="text-primary" />
|
2026-01-04 00:41:51 +00:00
|
|
|
) : (
|
2026-02-08 23:13:27 +00:00
|
|
|
<File size={14} className="text-muted-foreground" />
|
2026-01-04 00:41:51 +00:00
|
|
|
)}
|
2026-01-11 02:20:52 +00:00
|
|
|
<span className="truncate flex-1">{att.file_name}</span>
|
2026-01-04 00:41:51 +00:00
|
|
|
<button
|
|
|
|
|
onClick={() => removeAttachment(i)}
|
2026-02-08 23:14:40 +00:00
|
|
|
className="p-1 hover:bg-white/10 rounded-full text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
2026-01-04 00:41:51 +00:00
|
|
|
>
|
|
|
|
|
<X size={12} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-11 02:20:52 +00:00
|
|
|
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
2026-01-04 00:41:51 +00:00
|
|
|
<div className="flex gap-1">
|
2026-01-11 02:20:52 +00:00
|
|
|
<Button
|
2026-01-04 00:41:51 +00:00
|
|
|
type="button"
|
2026-01-11 02:20:52 +00:00
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
2026-02-08 23:13:27 +00:00
|
|
|
className="text-muted-foreground hover:text-white hover:bg-white/5"
|
2026-01-11 02:20:52 +00:00
|
|
|
onClick={() => fileInputRef.current?.click()}
|
2026-01-04 00:41:51 +00:00
|
|
|
>
|
|
|
|
|
<Paperclip size={20} />
|
2026-01-11 02:20:52 +00:00
|
|
|
</Button>
|
2026-01-04 00:41:51 +00:00
|
|
|
|
|
|
|
|
<div className="relative">
|
2026-01-11 02:20:52 +00:00
|
|
|
<Button
|
2026-01-04 00:41:51 +00:00
|
|
|
type="button"
|
2026-01-11 02:20:52 +00:00
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
2026-01-04 00:41:51 +00:00
|
|
|
className={cn(
|
2026-02-08 23:13:27 +00:00
|
|
|
'text-muted-foreground hover:text-white hover:bg-white/5',
|
2026-01-16 10:40:13 +00:00
|
|
|
showEmojiPicker && 'text-kodo-steel bg-white/5',
|
2026-01-04 00:41:51 +00:00
|
|
|
)}
|
|
|
|
|
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
|
|
|
|
>
|
|
|
|
|
<Smile size={20} />
|
2026-01-11 02:20:52 +00:00
|
|
|
</Button>
|
2026-01-04 00:41:51 +00:00
|
|
|
|
|
|
|
|
{showEmojiPicker && (
|
2026-01-11 02:20:52 +00:00
|
|
|
<div className="absolute bottom-full left-0 mb-4 z-50 animate-scaleIn origin-bottom-left">
|
2026-01-13 18:47:57 +00:00
|
|
|
<div
|
|
|
|
|
className="fixed inset-0"
|
|
|
|
|
onClick={() => setShowEmojiPicker(false)}
|
|
|
|
|
/>
|
2026-01-11 02:20:52 +00:00
|
|
|
<div className="relative shadow-2xl rounded-xl overflow-hidden border border-white/10">
|
2026-01-13 18:47:57 +00:00
|
|
|
<Suspense
|
|
|
|
|
fallback={
|
|
|
|
|
<div className="w-[350px] h-[450px] bg-kodo-ink flex items-center justify-center">
|
|
|
|
|
<LoadingSpinner />
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
2026-01-07 09:32:53 +00:00
|
|
|
<EmojiPicker
|
|
|
|
|
onEmojiClick={handleEmojiClick}
|
2026-01-11 02:20:52 +00:00
|
|
|
theme={Theme.DARK}
|
2026-01-07 09:32:53 +00:00
|
|
|
lazyLoadEmojis={true}
|
2026-01-11 02:20:52 +00:00
|
|
|
width={350}
|
|
|
|
|
height={450}
|
2026-01-07 09:32:53 +00:00
|
|
|
/>
|
|
|
|
|
</Suspense>
|
2026-01-04 00:41:51 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-11 02:20:52 +00:00
|
|
|
<div className="flex-1 relative">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={message}
|
|
|
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
|
|
|
placeholder="Broadcast message..."
|
2026-02-08 23:13:27 +00:00
|
|
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white placeholder:text-muted-foreground/50 focus:outline-none focus:border-border/50 focus:ring-1 focus:ring-kodo-steel/50 transition-all font-mono text-sm"
|
2026-01-11 02:20:52 +00:00
|
|
|
disabled={!currentConversationId || isUploading}
|
|
|
|
|
/>
|
|
|
|
|
{message.length === 0 && !isUploading && (
|
2026-01-26 13:12:17 +00:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
2026-02-08 23:13:27 +00:00
|
|
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 text-muted-foreground/30 hover:text-white"
|
2026-01-26 13:12:17 +00:00
|
|
|
>
|
|
|
|
|
<Mic className="w-4 h-4" />
|
|
|
|
|
</Button>
|
2026-01-11 02:20:52 +00:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-04 00:41:51 +00:00
|
|
|
|
2026-01-11 02:20:52 +00:00
|
|
|
<Button
|
2026-01-04 00:41:51 +00:00
|
|
|
type="submit"
|
2026-01-18 21:27:53 +00:00
|
|
|
variant="primary"
|
2026-01-11 02:20:52 +00:00
|
|
|
size="icon"
|
|
|
|
|
className={cn(
|
feat(web): UI premium Discord/Spotify-like — tokens, shadows, focus, layout
Plan UI premium 6–8 semaines (design system, shell, Storybook, a11y):
- Design system: DESIGN_TOKENS.md, APP_SHELL.md, FULL_LAYOUT_PAGE.md. Single source
for layout/shell (index.css), shadows (design-system.css), durations/easing.
- Tokens: shadow-cover-depth, shadow-gold-glow, shadow-fab-glow; layout max-height
(max-h-layout-drawer, max-h-layout-panel, max-h-layout-list). All duration-200/300/500
replaced by --duration-fast/normal/slow. Arbitrary shadows replaced by token classes.
- Shell & player: Sidebar, Header, GlobalPlayer, MiniPlayer, PlayerQueue, PlayerControls,
AudioPlayer use tokens; focus-visible on Sidebar, PlayerQueue, DropdownMenuTrigger/Item,
TabsTrigger. Typography: text-[10px]/[9px] → text-xs where applicable.
- ESLint: no-restricted-syntax (warn) for w-/h-/rounded-/shadow-/text-/spacing arbitrary.
- Scripts: report-arbitrary-values.mjs, capture/compare/generate visual; visual-complete.spec.ts.
- Stories full layout: Dashboard, Playlists, Library, Settings, Profile in DashboardLayout.stories.
- .cursorrules + README: DESIGN_TOKENS, APP_SHELL, visual commands, no arbitrary without justification.
- apps/web/.gitignore: e2e test artifacts (test-results-visual, playwright-report-visual).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 16:15:58 +00:00
|
|
|
'rounded-xl transition-all duration-[var(--duration-normal)]',
|
2026-01-13 18:47:57 +00:00
|
|
|
message.trim() || attachments.length > 0
|
ui(tokens): migrate kodo-cyan to primary (51 files, 88 instances)
Replace legacy text-kodo-cyan/border-kodo-cyan/bg-kodo-cyan with semantic
text-primary/border-primary/bg-primary across 51 components.
The brand primary color now uses the design system token, enabling proper
theme adaptation. Covers UI primitives, search, dashboard, chat, playlists,
settings, social, marketplace, and auth components.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 23:19:12 +00:00
|
|
|
? 'bg-primary text-kodo-void hover:bg-primary-dim shadow-neon-cyan'
|
2026-02-08 23:13:27 +00:00
|
|
|
: 'bg-white/5 text-muted-foreground hover:bg-white/10',
|
2026-01-11 02:20:52 +00:00
|
|
|
)}
|
2026-01-13 18:47:57 +00:00
|
|
|
disabled={
|
|
|
|
|
!currentConversationId ||
|
|
|
|
|
(!message.trim() && attachments.length === 0) ||
|
2026-01-15 19:01:47 +00:00
|
|
|
isUploading ||
|
|
|
|
|
isRateLimited
|
2026-01-13 18:47:57 +00:00
|
|
|
}
|
2026-01-04 00:41:51 +00:00
|
|
|
>
|
|
|
|
|
{isUploading ? (
|
2026-01-11 02:20:52 +00:00
|
|
|
<div className="w-5 h-5 border-2 border-kodo-void/30 border-t-kodo-void rounded-full animate-spin" />
|
2026-01-04 00:41:51 +00:00
|
|
|
) : (
|
2026-01-13 18:47:57 +00:00
|
|
|
<Send
|
|
|
|
|
size={18}
|
|
|
|
|
className={cn(message.trim() ? 'translate-x-0.5' : '')}
|
|
|
|
|
/>
|
2026-01-04 00:41:51 +00:00
|
|
|
)}
|
2026-01-11 02:20:52 +00:00
|
|
|
</Button>
|
2026-01-04 00:41:51 +00:00
|
|
|
</form>
|
|
|
|
|
</div>
|
2025-12-03 21:56:50 +00:00
|
|
|
);
|
2026-01-13 18:47:57 +00:00
|
|
|
};
|