All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
307 lines
10 KiB
TypeScript
307 lines
10 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;
|
|
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;
|
|
}
|
|
|
|
/** Format a date as relative time (e.g. "2h ago", "Yesterday"). */
|
|
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,
|
|
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((c) => !c.archived);
|
|
const archivedConversations = conversations.filter((c) => c.archived);
|
|
|
|
const filteredActive = searchQuery
|
|
? activeConversations.filter((c) =>
|
|
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
|
)
|
|
: activeConversations;
|
|
|
|
const filteredArchived = searchQuery
|
|
? archivedConversations.filter((c) =>
|
|
(c.title ?? 'Untitled').toLowerCase().includes(searchQuery.toLowerCase()),
|
|
)
|
|
: archivedConversations;
|
|
|
|
const handleContextMenu = useCallback((e: React.MouseEvent, conversationId: string) => {
|
|
e.preventDefault();
|
|
setContextMenu({ conversationId, x: e.clientX, y: e.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('');
|
|
}, [renamingId, renameValue, onRename]);
|
|
|
|
const cancelRename = useCallback(() => {
|
|
setRenamingId(null);
|
|
setRenameValue('');
|
|
}, []);
|
|
|
|
const handleRenameKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') commitRename();
|
|
if (e.key === 'Escape') cancelRename();
|
|
},
|
|
[commitRename, cancelRename],
|
|
);
|
|
|
|
const handleDeleteClick = useCallback((id: string) => {
|
|
setDeleteConfirmId(id);
|
|
}, []);
|
|
|
|
const confirmDelete = useCallback(
|
|
(id: string) => {
|
|
onDelete(id);
|
|
setDeleteConfirmId(null);
|
|
closeContextMenu();
|
|
},
|
|
[onDelete, closeContextMenu],
|
|
);
|
|
|
|
const handleArchiveToggle = useCallback(
|
|
(id: string, archived: boolean) => {
|
|
onArchive(id, archived);
|
|
closeContextMenu();
|
|
},
|
|
[onArchive, closeContextMenu],
|
|
);
|
|
|
|
const contextConv = contextMenu
|
|
? conversations.find((c) => c.id === contextMenu.conversationId)
|
|
: null;
|
|
|
|
function renderConversationItem(conv: Conversation): React.ReactElement {
|
|
const isActive = activeId === conv.id;
|
|
const isRenaming = renamingId === conv.id;
|
|
|
|
return (
|
|
<div key={conv.id} className="group relative">
|
|
{isRenaming ? (
|
|
<div className="px-3 py-2">
|
|
<input
|
|
ref={renameInputRef}
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onBlur={commitRename}
|
|
onKeyDown={handleRenameKeyDown}
|
|
className="w-full rounded border border-blue-500 bg-surface-elevated px-2 py-0.5 text-sm text-text-primary outline-none"
|
|
maxLength={255}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelect(conv.id)}
|
|
onDoubleClick={() => startRename(conv.id, conv.title)}
|
|
onContextMenu={(e) => handleContextMenu(e, conv.id)}
|
|
className={cn(
|
|
'w-full px-3 py-2 text-left text-sm transition-colors',
|
|
isActive
|
|
? 'bg-blue-600/20 text-blue-400'
|
|
: 'text-text-secondary hover:bg-surface-elevated',
|
|
)}
|
|
>
|
|
<span className="block truncate">{conv.title ?? 'Untitled'}</span>
|
|
<span className="block text-xs text-text-muted">
|
|
{formatRelativeTime(conv.updatedAt)}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop to close context menu */}
|
|
{contextMenu && (
|
|
<div className="fixed inset-0 z-10" onClick={closeContextMenu} aria-hidden="true" />
|
|
)}
|
|
|
|
<div className="flex h-full w-64 flex-col border-r border-surface-border bg-surface-card">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-3">
|
|
<h2 className="text-sm font-medium text-text-secondary">Conversations</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onNew}
|
|
className="rounded-md px-2 py-1 text-xs text-blue-400 transition-colors hover:bg-surface-elevated"
|
|
>
|
|
+ New
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search input */}
|
|
<div className="px-3 pb-2">
|
|
<input
|
|
type="search"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Search conversations\u2026"
|
|
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-xs text-text-primary placeholder:text-text-muted focus:border-blue-500 focus:outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Conversation list */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{filteredActive.length === 0 && !searchQuery && (
|
|
<p className="px-3 py-2 text-xs text-text-muted">No conversations yet</p>
|
|
)}
|
|
{filteredActive.length === 0 && searchQuery && (
|
|
<p className="px-3 py-2 text-xs text-text-muted">
|
|
No results for “{searchQuery}”
|
|
</p>
|
|
)}
|
|
{filteredActive.map((conv) => renderConversationItem(conv))}
|
|
|
|
{/* Archived section */}
|
|
{archivedConversations.length > 0 && (
|
|
<div className="mt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowArchived((v) => !v)}
|
|
className="flex w-full items-center gap-1 px-3 py-1 text-xs text-text-muted transition-colors hover:text-text-secondary"
|
|
>
|
|
<span
|
|
className={cn(
|
|
'inline-block transition-transform',
|
|
showArchived ? 'rotate-90' : '',
|
|
)}
|
|
>
|
|
►
|
|
</span>
|
|
Archived ({archivedConversations.length})
|
|
</button>
|
|
{showArchived && (
|
|
<div className="opacity-60">
|
|
{filteredArchived.map((conv) => renderConversationItem(conv))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Context menu */}
|
|
{contextMenu && contextConv && (
|
|
<div
|
|
className="fixed z-20 min-w-36 rounded-md border border-surface-border bg-surface-card py-1 shadow-lg"
|
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
|
|
onClick={() => startRename(contextConv.id, contextConv.title)}
|
|
>
|
|
Rename
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-1.5 text-left text-sm text-text-secondary hover:bg-surface-elevated"
|
|
onClick={() => handleArchiveToggle(contextConv.id, !contextConv.archived)}
|
|
>
|
|
{contextConv.archived ? 'Unarchive' : 'Archive'}
|
|
</button>
|
|
<hr className="my-1 border-surface-border" />
|
|
{deleteConfirmId === contextConv.id ? (
|
|
<div className="px-3 py-1.5">
|
|
<p className="mb-1.5 text-xs text-red-400">Delete this conversation?</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
className="rounded bg-red-600 px-2 py-0.5 text-xs text-white hover:bg-red-700"
|
|
onClick={() => confirmDelete(contextConv.id)}
|
|
>
|
|
Delete
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded px-2 py-0.5 text-xs text-text-muted hover:bg-surface-elevated"
|
|
onClick={() => setDeleteConfirmId(null)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="w-full px-3 py-1.5 text-left text-sm text-red-400 hover:bg-surface-elevated"
|
|
onClick={() => handleDeleteClick(contextConv.id)}
|
|
>
|
|
Delete
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|