Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
200 lines
9.5 KiB
JavaScript
200 lines
9.5 KiB
JavaScript
// ==========================================
|
|
// Chat Surface — SidebarChats Component
|
|
// ==========================================
|
|
// Personal AI chats with folder grouping and context menus.
|
|
|
|
const html = window.html;
|
|
const { useState, useCallback, useRef, useEffect } = window.hooks;
|
|
|
|
const CHEVRON_SVG = html`
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
<polyline points="9 18 15 12 9 6"/>
|
|
</svg>`;
|
|
|
|
const CHAT_SVG = html`
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
</svg>`;
|
|
|
|
const FOLDER_SVG = html`
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
|
</svg>`;
|
|
|
|
const DOTS_SVG = html`
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/>
|
|
</svg>`;
|
|
|
|
/**
|
|
* @param {{
|
|
* chatsByFolder: Record<string, Array>,
|
|
* folders: Array<{id: string, name: string}>,
|
|
* activeId: string|null,
|
|
* collapsed: boolean,
|
|
* onToggle: () => void,
|
|
* onSelect: (id: string) => void,
|
|
* onRename: (id: string, title: string) => void,
|
|
* onDelete: (id: string) => void,
|
|
* onMoveToFolder: (chatId: string, folderId: string|null) => void,
|
|
* onCreateFolder: (name: string) => void,
|
|
* onRenameFolder: (id: string, name: string) => void,
|
|
* onDeleteFolder: (id: string) => void,
|
|
* }} props
|
|
*/
|
|
export function SidebarChats({
|
|
chatsByFolder, folders, activeId, collapsed, onToggle, onSelect,
|
|
onRename, onDelete, onMoveToFolder, onCreateFolder,
|
|
onRenameFolder, onDeleteFolder,
|
|
}) {
|
|
const [contextMenu, setContextMenu] = useState(null);
|
|
const menuRef = useRef(null);
|
|
|
|
// Close context menu on outside click or Escape
|
|
useEffect(() => {
|
|
if (!contextMenu) return;
|
|
function _close(e) {
|
|
if (e.type === 'keydown' && e.key !== 'Escape') return;
|
|
if (e.type === 'click' && menuRef.current?.contains(e.target)) return;
|
|
setContextMenu(null);
|
|
}
|
|
document.addEventListener('click', _close);
|
|
document.addEventListener('keydown', _close);
|
|
return () => {
|
|
document.removeEventListener('click', _close);
|
|
document.removeEventListener('keydown', _close);
|
|
};
|
|
}, [contextMenu]);
|
|
|
|
const _openMenu = useCallback((e, type, item) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
setContextMenu({ type, item, x: rect.right, y: rect.bottom });
|
|
}, []);
|
|
|
|
const _action = useCallback(async (action, item) => {
|
|
setContextMenu(null);
|
|
if (action === 'rename') {
|
|
const newTitle = prompt('Rename:', item.title || item.name || '');
|
|
if (newTitle && newTitle.trim()) {
|
|
if (item.name !== undefined) await onRenameFolder(item.id, newTitle.trim());
|
|
else await onRename(item.id, newTitle.trim());
|
|
}
|
|
} else if (action === 'delete') {
|
|
const label = item.name !== undefined ? 'folder' : 'chat';
|
|
if (confirm('Delete this ' + label + '?')) {
|
|
if (item.name !== undefined) await onDeleteFolder(item.id);
|
|
else await onDelete(item.id);
|
|
}
|
|
} else if (action === 'move-out') {
|
|
await onMoveToFolder(item.id, null);
|
|
} else if (action === 'new-folder') {
|
|
const name = prompt('Folder name:');
|
|
if (name && name.trim()) await onCreateFolder(name.trim());
|
|
} else if (action.startsWith('move-to:')) {
|
|
const fid = action.slice(8);
|
|
await onMoveToFolder(item.id, fid);
|
|
}
|
|
}, [onRename, onDelete, onMoveToFolder, onCreateFolder, onRenameFolder, onDeleteFolder]);
|
|
|
|
// Count total chats
|
|
const totalChats = Object.values(chatsByFolder).reduce((sum, arr) => sum + arr.length, 0);
|
|
const unfolderedChats = chatsByFolder[''] || [];
|
|
|
|
return html`
|
|
<div class="sw-chat-surface__section">
|
|
<button class="sw-chat-surface__section-header"
|
|
onClick=${onToggle}
|
|
aria-expanded=${!collapsed}>
|
|
<span class=${'sw-chat-surface__chevron' + (collapsed ? '' : ' sw-chat-surface__chevron--open')}>
|
|
${CHEVRON_SVG}
|
|
</span>
|
|
<span class="sw-chat-surface__section-label">Chats</span>
|
|
${totalChats > 0 && html`
|
|
<span class="sw-chat-surface__section-count">${totalChats}</span>`}
|
|
</button>
|
|
${!collapsed && html`
|
|
<div class="sw-chat-surface__section-body" role="listbox">
|
|
${/* Folders first */''}
|
|
${folders.map(folder => {
|
|
const folderChats = chatsByFolder[folder.id] || [];
|
|
return html`
|
|
<div key=${'f-' + folder.id} class="sw-chat-surface__folder">
|
|
<div class="sw-chat-surface__folder-header">
|
|
<span class="sw-chat-surface__item-icon">${FOLDER_SVG}</span>
|
|
<span class="sw-chat-surface__folder-name">${folder.name}</span>
|
|
<span class="sw-chat-surface__folder-count">${folderChats.length}</span>
|
|
<button class="sw-chat-surface__item-menu"
|
|
onClick=${(e) => _openMenu(e, 'folder', folder)}
|
|
title="Folder actions"
|
|
aria-label="Folder actions">
|
|
${DOTS_SVG}
|
|
</button>
|
|
</div>
|
|
${folderChats.map(ch => html`
|
|
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
|
|
onSelect=${onSelect} onOpenMenu=${_openMenu}
|
|
indent=${true} />`)}
|
|
</div>`;
|
|
})}
|
|
${/* Unfoldered chats */''}
|
|
${unfolderedChats.map(ch => html`
|
|
<${ChatItem} key=${ch.id} chat=${ch} activeId=${activeId}
|
|
onSelect=${onSelect} onOpenMenu=${_openMenu} />`)}
|
|
${totalChats === 0 && html`
|
|
<div class="sw-chat-surface__empty">No chats yet</div>`}
|
|
</div>`}
|
|
|
|
${/* Context Menu */''}
|
|
${contextMenu && html`
|
|
<div class="sw-chat-surface__context-menu" ref=${menuRef}
|
|
style=${'left:' + contextMenu.x + 'px;top:' + contextMenu.y + 'px;'}>
|
|
${contextMenu.type === 'chat' && html`
|
|
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
|
|
${folders.length > 0 && folders.map(f => html`
|
|
<button key=${f.id} onClick=${() => _action('move-to:' + f.id, contextMenu.item)}>
|
|
Move to ${f.name}
|
|
</button>`)}
|
|
${contextMenu.item.folder_id && html`
|
|
<button onClick=${() => _action('move-out', contextMenu.item)}>Remove from folder</button>`}
|
|
<button class="sw-chat-surface__ctx-danger"
|
|
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
|
|
`}
|
|
${contextMenu.type === 'folder' && html`
|
|
<button onClick=${() => _action('rename', contextMenu.item)}>Rename</button>
|
|
<button class="sw-chat-surface__ctx-danger"
|
|
onClick=${() => _action('delete', contextMenu.item)}>Delete</button>
|
|
`}
|
|
</div>`}
|
|
</div>`;
|
|
}
|
|
|
|
function ChatItem({ chat, activeId, onSelect, onOpenMenu, indent }) {
|
|
return html`
|
|
<button class=${'sw-chat-surface__item' + (chat.id === activeId ? ' sw-chat-surface__item--active' : '') + (indent ? ' sw-chat-surface__item--indent' : '')}
|
|
onClick=${() => onSelect(chat.id)}
|
|
onContextMenu=${(e) => { e.preventDefault(); onOpenMenu(e, 'chat', chat); }}
|
|
role="option"
|
|
aria-selected=${chat.id === activeId}
|
|
title=${chat.title || 'Untitled'}>
|
|
<span class="sw-chat-surface__item-icon">${CHAT_SVG}</span>
|
|
<span class="sw-chat-surface__item-title">${chat.title || 'Untitled'}</span>
|
|
<button class="sw-chat-surface__item-menu"
|
|
onClick=${(e) => _stopAndOpen(e, onOpenMenu, 'chat', chat)}
|
|
title="Chat actions"
|
|
aria-label="Chat actions">
|
|
${DOTS_SVG}
|
|
</button>
|
|
</button>`;
|
|
}
|
|
|
|
function _stopAndOpen(e, onOpenMenu, type, item) {
|
|
e.stopPropagation();
|
|
onOpenMenu(e, type, item);
|
|
}
|