Upload files to "/"
This commit is contained in:
parent
41df864146
commit
e395736dbf
|
|
@ -0,0 +1,671 @@
|
|||
# 🚀 ChatJS Best Practices - Second Brain Integration Guide
|
||||
|
||||
## Overview
|
||||
This document contains advanced patterns from [ChatJS](https://github.com/FranciscoMoretti/chatjs) that should be integrated into the Second Brain project to achieve production-grade quality.
|
||||
|
||||
---
|
||||
|
||||
## 1. 📜 Smart Scroll Management
|
||||
|
||||
**Problem**: Current implementation scrolls even when user is reading previous messages.
|
||||
|
||||
**ChatJS Solution**: `use-scroll-anchor.ts`
|
||||
|
||||
```typescript
|
||||
// hooks/use-scroll-anchor.ts
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export function useScrollAnchor() {
|
||||
const messagesRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const isAtBottomRef = useRef(true)
|
||||
const [showScrollButton, setShowScrollButton] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const messagesEl = messagesRef.current
|
||||
const scrollEl = scrollRef.current
|
||||
|
||||
if (!messagesEl || !scrollEl) return
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollEl
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
|
||||
// User is at bottom if within 50px
|
||||
isAtBottomRef.current = distanceFromBottom < 50
|
||||
setShowScrollButton(!isAtBottomRef.current)
|
||||
}
|
||||
|
||||
scrollEl.addEventListener('scroll', handleScroll)
|
||||
return () => scrollEl.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const messagesEl = messagesRef.current
|
||||
if (messagesEl) {
|
||||
messagesEl.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll ONLY if user is at bottom
|
||||
const autoScroll = () => {
|
||||
if (isAtBottomRef.current) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messagesRef,
|
||||
scrollRef,
|
||||
showScrollButton,
|
||||
scrollToBottom,
|
||||
autoScroll,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage in ChatPage**:
|
||||
```typescript
|
||||
export function ChatPage() {
|
||||
const { messages, isTyping } = useChatStore()
|
||||
const { messagesRef, scrollRef, showScrollButton, scrollToBottom, autoScroll } = useScrollAnchor()
|
||||
|
||||
useEffect(() => {
|
||||
autoScroll() // Only scrolls if user is at bottom
|
||||
}, [messages, isTyping])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
||||
<div className="py-8">
|
||||
{messages.map(msg => <ChatMessage key={msg.id} message={msg} />)}
|
||||
{isTyping && <TypingIndicator />}
|
||||
<div ref={messagesRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll to Bottom Button */}
|
||||
{showScrollButton && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="absolute bottom-24 right-8 p-3 bg-primary rounded-full shadow-lg"
|
||||
>
|
||||
<ChevronDown className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ChatInput />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 🎬 Message Actions
|
||||
|
||||
**ChatJS has excellent message actions on hover.**
|
||||
|
||||
```typescript
|
||||
// components/chat/MessageActions.tsx
|
||||
import { Copy, RotateCw, Edit, Share, MoreHorizontal } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface MessageActionsProps {
|
||||
message: Message
|
||||
onRegenerate?: () => void
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
export function MessageActions({ message, onRegenerate, onEdit }: MessageActionsProps) {
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(message.content)
|
||||
toast.success('Copied to clipboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{/* Copy */}
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="p-1.5 hover:bg-zinc-800 rounded transition-colors"
|
||||
title="Copy"
|
||||
>
|
||||
<Copy className="w-4 h-4 text-zinc-400" />
|
||||
</button>
|
||||
|
||||
{/* Regenerate (AI messages only) */}
|
||||
{message.role === 'assistant' && onRegenerate && (
|
||||
<button
|
||||
onClick={onRegenerate}
|
||||
className="p-1.5 hover:bg-zinc-800 rounded transition-colors"
|
||||
title="Regenerate"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-zinc-400" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Edit (User messages only) */}
|
||||
{message.role === 'user' && onEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1.5 hover:bg-zinc-800 rounded transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4 text-zinc-400" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Share */}
|
||||
<button
|
||||
className="p-1.5 hover:bg-zinc-800 rounded transition-colors"
|
||||
title="Share"
|
||||
>
|
||||
<Share className="w-4 h-4 text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Update ChatMessage to include actions**:
|
||||
```typescript
|
||||
export function ChatMessage({ message }: ChatMessageProps) {
|
||||
const { regenerateMessage } = useChatStore()
|
||||
const isUser = message.role === 'user'
|
||||
|
||||
return (
|
||||
<div className="group flex gap-3 max-w-4xl px-6 py-4 relative">
|
||||
{/* Avatar */}
|
||||
<div className={/* ... */}>
|
||||
{/* ... */}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
{/* ... existing content ... */}
|
||||
</div>
|
||||
|
||||
{/* Actions - appears on hover */}
|
||||
<MessageActions
|
||||
message={message}
|
||||
onRegenerate={() => regenerateMessage(message.id)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 📎 Advanced File Upload
|
||||
|
||||
**ChatJS has excellent drag & drop with preview.**
|
||||
|
||||
```typescript
|
||||
// components/chat/FileUploadZone.tsx
|
||||
import { useState } from 'react'
|
||||
import { Upload, X, File, Image } from 'lucide-react'
|
||||
|
||||
interface FileUploadZoneProps {
|
||||
onFilesSelect: (files: File[]) => void
|
||||
selectedFiles: File[]
|
||||
onFileRemove: (index: number) => void
|
||||
}
|
||||
|
||||
export function FileUploadZone({ onFilesSelect, selectedFiles, onFileRemove }: FileUploadZoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
onFilesSelect(files)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Drag & Drop Zone */}
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-8 transition-all
|
||||
${isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-zinc-700 hover:border-zinc-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Upload className={`w-8 h-8 ${isDragging ? 'text-primary' : 'text-zinc-500'}`} />
|
||||
<p className="text-sm text-zinc-400">
|
||||
{isDragging ? 'Drop files here' : 'Drag files here or click to browse'}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-600">
|
||||
Supports: Images, PDFs, Documents (max 10MB)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Previews */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<FilePreviewCard
|
||||
key={index}
|
||||
file={file}
|
||||
onRemove={() => onFileRemove(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// File Preview Card
|
||||
function FilePreviewCard({ file, onRemove }: { file: File; onRemove: () => void }) {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isImage) {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => setPreview(reader.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
}, [file, isImage])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 bg-zinc-900 border border-zinc-800 rounded-lg">
|
||||
{/* Icon or Image Preview */}
|
||||
{isImage && preview ? (
|
||||
<img src={preview} alt={file.name} className="w-12 h-12 object-cover rounded" />
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-zinc-800 rounded flex items-center justify-center">
|
||||
<File className="w-6 h-6 text-zinc-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-zinc-200 truncate">{file.name}</p>
|
||||
<p className="text-xs text-zinc-500">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="p-1 hover:bg-zinc-800 rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-zinc-500" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. ⌨️ Advanced Keyboard Shortcuts
|
||||
|
||||
**ChatJS has a keyboard shortcuts modal.**
|
||||
|
||||
```typescript
|
||||
// components/KeyboardShortcutsModal.tsx
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function KeyboardShortcutsModal() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Cmd+/ or Ctrl+/ to toggle
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === '/') {
|
||||
e.preventDefault()
|
||||
setIsOpen(prev => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ['Cmd', 'N'], action: 'New chat' },
|
||||
{ keys: ['Cmd', 'K'], action: 'Search conversations' },
|
||||
{ keys: ['Cmd', 'L'], action: 'Go to library' },
|
||||
{ keys: ['Cmd', 'M'], action: 'Go to neural map' },
|
||||
{ keys: ['Cmd', '/'], action: 'Show keyboard shortcuts' },
|
||||
{ keys: ['Escape'], action: 'Close modal' },
|
||||
{ keys: ['Cmd', 'Enter'], action: 'Send message' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 max-w-md w-full">
|
||||
<h2 className="text-xl font-bold text-zinc-100 mb-4">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
|
||||
<div className="space-y-2">
|
||||
{shortcuts.map((shortcut, i) => (
|
||||
<div key={i} className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-zinc-400">{shortcut.action}</span>
|
||||
<div className="flex gap-1">
|
||||
{shortcut.keys.map((key, j) => (
|
||||
<kbd
|
||||
key={j}
|
||||
className="px-2 py-1 bg-zinc-800 border border-zinc-700 rounded text-xs font-mono text-zinc-300"
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="mt-6 w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 🔄 Regenerate Message
|
||||
|
||||
**Add to chatStore.ts**:
|
||||
|
||||
```typescript
|
||||
interface ChatState {
|
||||
messages: Message[]
|
||||
isTyping: boolean
|
||||
sendMessage: (params: SendMessageParams) => Promise<void>
|
||||
regenerateMessage: (messageId: string) => Promise<void> // NEW
|
||||
clearMessages: () => void
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// ... existing code ...
|
||||
|
||||
regenerateMessage: async (messageId: string) => {
|
||||
const { messages } = get()
|
||||
const messageIndex = messages.findIndex(m => m.id === messageId)
|
||||
|
||||
if (messageIndex === -1) return
|
||||
|
||||
// Find the last user message before this AI message
|
||||
const userMessage = messages
|
||||
.slice(0, messageIndex)
|
||||
.reverse()
|
||||
.find(m => m.role === 'user')
|
||||
|
||||
if (!userMessage) return
|
||||
|
||||
// Remove the AI message and everything after it
|
||||
set({ messages: messages.slice(0, messageIndex) })
|
||||
|
||||
// Regenerate from user message
|
||||
set({ isTyping: true })
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: userMessage.content,
|
||||
// Use same model/rag settings as original
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
const newMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: data.answer,
|
||||
timestamp: new Date(),
|
||||
sources: data.sources,
|
||||
}
|
||||
|
||||
set(state => ({ messages: [...state.messages, newMessage] }))
|
||||
} catch (err) {
|
||||
console.error('Regenerate error:', err)
|
||||
} finally {
|
||||
set({ isTyping: false })
|
||||
}
|
||||
},
|
||||
}))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 📱 Mobile Improvements
|
||||
|
||||
**ChatJS has excellent mobile UX.**
|
||||
|
||||
### Mobile-Specific Improvements:
|
||||
|
||||
```typescript
|
||||
// components/chat/MobileOptimizations.tsx
|
||||
|
||||
// 1. Virtual Keyboard Handling
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
// Adjust viewport when keyboard opens
|
||||
const vh = window.innerHeight * 0.01
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
handleResize()
|
||||
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
// 2. Touch Gestures for Sidebar
|
||||
const handleSwipe = (e: TouchEvent) => {
|
||||
// Swipe from left edge to open sidebar
|
||||
if (e.touches[0].clientX < 20) {
|
||||
openSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Prevent Zoom on Double-Tap
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
|
||||
// 4. Safe Area Insets (for iPhone notch)
|
||||
<div className="pb-[env(safe-area-inset-bottom)]">
|
||||
<ChatInput />
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 🎨 Loading States
|
||||
|
||||
**ChatJS has skeleton loaders everywhere.**
|
||||
|
||||
```typescript
|
||||
// components/ui/MessageSkeleton.tsx
|
||||
export function MessageSkeleton() {
|
||||
return (
|
||||
<div className="flex gap-3 max-w-4xl px-6 py-4 animate-pulse">
|
||||
<div className="w-8 h-8 rounded-full bg-zinc-800" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 bg-zinc-800 rounded w-1/4" />
|
||||
<div className="h-4 bg-zinc-800 rounded w-3/4" />
|
||||
<div className="h-4 bg-zinc-800 rounded w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
{isLoading ? (
|
||||
<>
|
||||
<MessageSkeleton />
|
||||
<MessageSkeleton />
|
||||
</>
|
||||
) : (
|
||||
messages.map(msg => <ChatMessage key={msg.id} message={msg} />)
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 🔔 Better Error Handling
|
||||
|
||||
**ChatJS shows user-friendly errors.**
|
||||
|
||||
```typescript
|
||||
// lib/error-handler.ts
|
||||
export function handleChatError(error: unknown) {
|
||||
console.error('Chat error:', error)
|
||||
|
||||
if (error instanceof Error) {
|
||||
// Network errors
|
||||
if (error.message.includes('Failed to fetch')) {
|
||||
toast.error('Connection lost. Check your internet.')
|
||||
return
|
||||
}
|
||||
|
||||
// API errors
|
||||
if (error.message.includes('429')) {
|
||||
toast.error('Rate limit exceeded. Please wait a moment.')
|
||||
return
|
||||
}
|
||||
|
||||
if (error.message.includes('401')) {
|
||||
toast.error('Session expired. Please log in again.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generic error
|
||||
toast.error('Something went wrong. Please try again.')
|
||||
}
|
||||
|
||||
// Usage in chatStore
|
||||
catch (err) {
|
||||
handleChatError(err)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 💾 Local Storage Persistence
|
||||
|
||||
**ChatJS saves draft messages.**
|
||||
|
||||
```typescript
|
||||
// hooks/useLocalStorage.ts
|
||||
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : initialValue
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
const setValue = (value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||||
setStoredValue(valueToStore)
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
return [storedValue, setValue] as const
|
||||
}
|
||||
|
||||
// Usage in ChatInput
|
||||
export function ChatInput() {
|
||||
const [draftInput, setDraftInput] = useLocalStorage('chat-draft', '')
|
||||
|
||||
const handleSend = () => {
|
||||
// ... send logic
|
||||
setDraftInput('') // Clear draft
|
||||
}
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={draftInput}
|
||||
onChange={(e) => setDraftInput(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 🎯 Implementation Priority
|
||||
|
||||
**For Second Brain, implement in this order:**
|
||||
|
||||
1. ✅ **Smart Scroll** (useScrollAnchor) - Immediate UX improvement
|
||||
2. ✅ **Message Actions** (Copy, Regenerate) - Professional feel
|
||||
3. ✅ **Better File Upload** (Drag & drop with preview) - Core feature
|
||||
4. ✅ **Keyboard Shortcuts Modal** - Power user feature
|
||||
5. ⏳ **Loading Skeletons** - Polish
|
||||
6. ⏳ **Error Handling** - Reliability
|
||||
7. ⏳ **Draft Persistence** - Nice to have
|
||||
8. ⏳ **Mobile Optimizations** - Once desktop is solid
|
||||
|
||||
---
|
||||
|
||||
## 📝 Summary
|
||||
|
||||
ChatJS demonstrates production-grade patterns that Second Brain should adopt:
|
||||
|
||||
- **Smart scrolling** prevents annoying auto-scroll
|
||||
- **Message actions** make the chat feel professional
|
||||
- **File previews** improve upload UX dramatically
|
||||
- **Keyboard shortcuts** delight power users
|
||||
- **Error handling** builds trust
|
||||
- **Loading states** feel responsive
|
||||
|
||||
The key insight: **Small details compound into a delightful experience.**
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Resources
|
||||
|
||||
- ChatJS Live Demo: https://chatjs.dev
|
||||
- ChatJS Docs: https://chatjs.dev/docs
|
||||
- ChatJS GitHub: https://github.com/FranciscoMoretti/chatjs
|
||||
|
||||
Use this as reference when polishing Second Brain! 🚀
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue