329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useRef, useState } from 'react';
|
|
import { cn } from '@/lib/cn';
|
|
import type { Conversation } from '@/lib/types';
|
|
|
|
interface ConversationListProps {
|
|
conversations: Conversation[];
|
|
activeId: string | null;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onSelect: (id: string) => void;
|
|
onNew: () => void;
|
|
onRename: (id: string, title: string) => void;
|
|
onDelete: (id: string) => void;
|
|
onArchive: (id: string, archived: boolean) => void;
|
|
}
|
|
|
|
interface ContextMenuState {
|
|
conversationId: string;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
function formatRelativeTime(dateStr: string): string {
|
|
const date = new Date(dateStr);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMinutes = Math.floor(diffMs / 60_000);
|
|
const diffHours = Math.floor(diffMs / 3_600_000);
|
|
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
|
|
if (diffMinutes < 1) return 'Just now';
|
|
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays === 1) return 'Yesterday';
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
export function ConversationList({
|
|
conversations,
|
|
activeId,
|
|
isOpen,
|
|
onClose,
|
|
onSelect,
|
|
onNew,
|
|
onRename,
|
|
onDelete,
|
|
onArchive,
|
|
}: ConversationListProps): React.ReactElement {
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState('');
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
|
const [showArchived, setShowArchived] = useState(false);
|
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const activeConversations = conversations.filter((conversation) => !conversation.archived);
|
|
const archivedConversations = conversations.filter((conversation) => conversation.archived);
|
|
|
|
const filteredActive = searchQuery
|
|
? activeConversations.filter((conversation) =>
|
|
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
|
)
|
|
: activeConversations;
|
|
|
|
const filteredArchived = searchQuery
|
|
? archivedConversations.filter((conversation) =>
|
|
(conversation.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
|
)
|
|
: archivedConversations;
|
|
|
|
const handleContextMenu = useCallback((event: React.MouseEvent, conversationId: string) => {
|
|
event.preventDefault();
|
|
setContextMenu({ conversationId, x: event.clientX, y: event.clientY });
|
|
setDeleteConfirmId(null);
|
|
}, []);
|
|
|
|
const closeContextMenu = useCallback(() => {
|
|
setContextMenu(null);
|
|
setDeleteConfirmId(null);
|
|
}, []);
|
|
|
|
const startRename = useCallback(
|
|
(id: string, currentTitle: string | null) => {
|
|
setRenamingId(id);
|
|
setRenameValue(currentTitle ?? '');
|
|
closeContextMenu();
|
|
setTimeout(() => renameInputRef.current?.focus(), 0);
|
|
},
|
|
[closeContextMenu],
|
|
);
|
|
|
|
const commitRename = useCallback(() => {
|
|
if (renamingId) {
|
|
const trimmed = renameValue.trim();
|
|
onRename(renamingId, trimmed || 'Untitled');
|
|
}
|
|
setRenamingId(null);
|
|
setRenameValue('');
|
|
}, [onRename, renameValue, renamingId]);
|
|
|
|
const cancelRename = useCallback(() => {
|
|
setRenamingId(null);
|
|
setRenameValue('');
|
|
}, []);
|
|
|
|
const handleRenameKeyDown = useCallback(
|
|
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (event.key === 'Enter') commitRename();
|
|
if (event.key === 'Escape') cancelRename();
|
|
},
|
|
[cancelRename, commitRename],
|
|
);
|
|
|
|
const confirmDelete = useCallback(
|
|
(id: string) => {
|
|
onDelete(id);
|
|
setDeleteConfirmId(null);
|
|
closeContextMenu();
|
|
},
|
|
[closeContextMenu, onDelete],
|
|
);
|
|
|
|
const handleArchiveToggle = useCallback(
|
|
(id: string, archived: boolean) => {
|
|
onArchive(id, archived);
|
|
closeContextMenu();
|
|
},
|
|
[closeContextMenu, onArchive],
|
|
);
|
|
|
|
const contextConversation = contextMenu
|
|
? conversations.find((conversation) => conversation.id === contextMenu.conversationId)
|
|
: null;
|
|
|
|
function renderConversationItem(conversation: Conversation): React.ReactElement {
|
|
const isActive = activeId === conversation.id;
|
|
const isRenaming = renamingId === conversation.id;
|
|
|
|
return (
|
|
<div key={conversation.id} className="group relative">
|
|
{isRenaming ? (
|
|
<div className="px-3 py-2">
|
|
<input
|
|
ref={renameInputRef}
|
|
value={renameValue}
|
|
onChange={(event) => setRenameValue(event.target.value)}
|
|
onBlur={commitRename}
|
|
onKeyDown={handleRenameKeyDown}
|
|
className="w-full rounded-xl border px-3 py-2 text-sm outline-none"
|
|
style={{
|
|
borderColor: 'var(--color-ms-blue-500)',
|
|
backgroundColor: 'var(--color-surface-2)',
|
|
color: 'var(--color-text)',
|
|
}}
|
|
maxLength={255}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onSelect(conversation.id);
|
|
if (window.innerWidth < 768) onClose();
|
|
}}
|
|
onDoubleClick={() => startRename(conversation.id, conversation.title)}
|
|
onContextMenu={(event) => handleContextMenu(event, conversation.id)}
|
|
className={cn(
|
|
'w-full rounded-2xl px-3 py-2 text-left text-sm transition-colors',
|
|
isActive ? 'shadow-[var(--shadow-ms-sm)]' : 'hover:bg-white/5',
|
|
)}
|
|
style={{
|
|
backgroundColor: isActive
|
|
? 'color-mix(in srgb, var(--color-ms-blue-500) 22%, transparent)'
|
|
: 'transparent',
|
|
color: isActive ? 'var(--color-text)' : 'var(--color-text-2)',
|
|
}}
|
|
>
|
|
<span className="block truncate font-medium">{conversation.title ?? 'Untitled'}</span>
|
|
<span className="block text-xs text-[var(--color-muted)]">
|
|
{formatRelativeTime(conversation.updatedAt)}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{isOpen ? (
|
|
<button
|
|
type="button"
|
|
className="fixed inset-0 z-20 bg-black/45 md:hidden"
|
|
onClick={onClose}
|
|
aria-label="Close conversation sidebar"
|
|
/>
|
|
) : null}
|
|
|
|
{contextMenu ? (
|
|
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
|
|
) : null}
|
|
|
|
<div
|
|
className={cn(
|
|
'fixed inset-y-0 left-0 z-30 flex h-full w-[18.5rem] flex-col border-r px-3 py-3 transition-transform duration-200 md:static md:z-auto',
|
|
isOpen
|
|
? 'translate-x-0'
|
|
: '-translate-x-full md:w-0 md:min-w-0 md:overflow-hidden md:border-r-0 md:px-0 md:py-0',
|
|
)}
|
|
style={{
|
|
backgroundColor: 'var(--color-surface)',
|
|
borderColor: 'var(--color-border)',
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between px-1 pb-3">
|
|
<h2 className="text-sm font-medium text-[var(--color-text-2)]">Conversations</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onNew}
|
|
className="rounded-full px-3 py-1 text-xs transition-colors hover:bg-white/5"
|
|
style={{ color: 'var(--color-ms-blue-400)' }}
|
|
>
|
|
+ New
|
|
</button>
|
|
</div>
|
|
|
|
<div className="pb-3">
|
|
<input
|
|
type="search"
|
|
value={searchQuery}
|
|
onChange={(event) => setSearchQuery(event.target.value)}
|
|
placeholder="Search conversations…"
|
|
className="w-full rounded-2xl border px-3 py-2 text-xs placeholder:text-[var(--color-muted)] focus:outline-none"
|
|
style={{
|
|
backgroundColor: 'var(--color-surface-2)',
|
|
borderColor: 'var(--color-border)',
|
|
color: 'var(--color-text)',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto space-y-1">
|
|
{filteredActive.length === 0 && !searchQuery ? (
|
|
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">No conversations yet</p>
|
|
) : null}
|
|
{filteredActive.length === 0 && searchQuery ? (
|
|
<p className="px-1 py-2 text-xs text-[var(--color-muted)]">
|
|
No results for “{searchQuery}”
|
|
</p>
|
|
) : null}
|
|
{filteredActive.map((conversation) => renderConversationItem(conversation))}
|
|
|
|
{archivedConversations.length > 0 ? (
|
|
<div className="pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowArchived((prev) => !prev)}
|
|
className="flex w-full items-center gap-2 px-1 py-1 text-xs text-[var(--color-muted)] transition-colors hover:text-[var(--color-text-2)]"
|
|
>
|
|
<span
|
|
className={cn('inline-block transition-transform', showArchived && 'rotate-90')}
|
|
>
|
|
▶
|
|
</span>
|
|
Archived ({archivedConversations.length})
|
|
</button>
|
|
{showArchived ? (
|
|
<div className="mt-1 space-y-1 opacity-70">
|
|
{filteredArchived.map((conversation) => renderConversationItem(conversation))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{contextMenu && contextConversation ? (
|
|
<div
|
|
className="fixed z-30 min-w-40 rounded-2xl border py-1 shadow-[var(--shadow-ms-lg)]"
|
|
style={{
|
|
top: contextMenu.y,
|
|
left: contextMenu.x,
|
|
backgroundColor: 'var(--color-surface)',
|
|
borderColor: 'var(--color-border)',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
|
onClick={() => startRename(contextConversation.id, contextConversation.title)}
|
|
>
|
|
Rename
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left text-sm text-[var(--color-text-2)] transition-colors hover:bg-white/5"
|
|
onClick={() =>
|
|
handleArchiveToggle(contextConversation.id, !contextConversation.archived)
|
|
}
|
|
>
|
|
{contextConversation.archived ? 'Restore' : 'Archive'}
|
|
</button>
|
|
{deleteConfirmId === contextConversation.id ? (
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5"
|
|
onClick={() => confirmDelete(contextConversation.id)}
|
|
>
|
|
Confirm delete
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-2 text-left text-sm text-[var(--color-danger)] transition-colors hover:bg-white/5"
|
|
onClick={() => setDeleteConfirmId(contextConversation.id)}
|
|
>
|
|
Delete…
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|