File size: 6,391 Bytes
ca51841 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 | import { store } from '../store.js';
import { icon } from '../icons.js';
function relativeTime(isoString) {
const now = Date.now();
const then = new Date(isoString).getTime();
const diff = Math.floor((now - then) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return new Date(isoString).toLocaleDateString();
}
export class Sidebar {
constructor() {
this.el = null;
this._mobileOpen = false;
}
render() {
const wrapper = document.createElement('div');
wrapper.className = 'relative';
wrapper.innerHTML = this._template();
this.el = wrapper.firstElementChild;
this._bindEvents();
return this.el;
}
_template() {
const convs = store.getConversations();
const currentId = store.getCurrentConversationId();
const items = convs.map(conv => {
const active = conv.id === currentId;
return `
<div class="group relative flex items-center gap-2 rounded-xl cursor-pointer border px-3 py-2.5 transition-all ${active ? 'border-[var(--c-side-act-bd)] bg-[var(--c-side-act)]' : 'border-transparent hover:border-[var(--c-side-bd)] hover:bg-[var(--c-side-el)]'}" data-conv-id="${conv.id}" role="option" aria-selected="${active}">
<div class="flex-1 min-w-0">
<div class="text-[13px] truncate ${active ? 'font-medium text-[var(--c-side-tx)]' : 'text-[var(--c-side-tx2)]'}">${escapeHtml(conv.title || 'New Chat')}</div>
<div class="mt-0.5 truncate text-[11px] text-[var(--c-side-tx3)]">${relativeTime(conv.updatedAt || conv.createdAt)}</div>
</div>
<button class="delete-conv flex-shrink-0 p-1 rounded-md text-[var(--c-side-tx3)] opacity-0 transition-all group-hover:opacity-100 hover:bg-[var(--c-side-el-h)] hover:text-[var(--c-side-tx)]" data-conv-id="${conv.id}" aria-label="Delete conversation">
${icon('trash')}
</button>
</div>
`;
}).join('');
return `
<aside id="sidebar" class="sidebar-transition flex h-full w-56 flex-col border-r border-[var(--c-side-bd)] bg-[var(--c-side)] flex-shrink-0">
<div class="px-3 pt-3 pb-2">
<button id="new-chat-btn" class="flex w-full items-center justify-center gap-1.5 rounded-xl border border-[var(--c-side-bd)] bg-[var(--c-side-el)] px-3 py-2.5 text-[12px] text-[var(--c-side-tx2)] transition-all hover:bg-[var(--c-side-el-h)] hover:text-[var(--c-side-tx)] hover:border-[var(--c-side-act-bd)]" aria-label="New chat">
${icon('plus')} <span>New Chat</span>
</button>
</div>
<div id="conv-list" class="flex-1 overflow-y-auto pb-2 px-2 space-y-px" role="listbox" aria-label="Conversations">
${items || '<div class="px-4 py-10 text-center text-[11px] leading-relaxed text-[var(--c-side-tx3)]">No conversations yet.<br>Start a new chat!</div>'}
</div>
<div class="border-t border-[var(--c-side-bd)] px-3 py-3">
<div class="flex items-center gap-2 rounded-xl px-1.5 py-1">
<div class="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg border border-[var(--c-side-bd)] bg-[var(--c-side-el)]">
<img src="/logo.svg" alt="" class="h-4 w-4 object-contain" aria-hidden="true" />
</div>
<div class="min-w-0">
<div class="truncate text-[10px] font-semibold uppercase tracking-[0.16em] text-[var(--c-side-tx2)]">AXERA</div>
<div class="truncate text-[10px] text-[var(--c-side-tx3)]">Lite WebUI</div>
</div>
</div>
</div>
</aside>
`;
}
_bindEvents() {
this.el.querySelector('#new-chat-btn').addEventListener('click', () => {
document.dispatchEvent(new CustomEvent('sidebar:newchat'));
});
this.el.querySelector('#conv-list').addEventListener('click', (e) => {
const delBtn = e.target.closest('.delete-conv');
if (delBtn) {
e.stopPropagation();
const convId = delBtn.dataset.convId;
if (confirm('Delete this conversation?')) {
store.deleteConversation(convId);
document.dispatchEvent(new CustomEvent('sidebar:deleted', { detail: { convId } }));
this.update();
}
return;
}
const item = e.target.closest('[data-conv-id]');
if (item && !item.classList.contains('delete-conv')) {
const convId = item.dataset.convId;
document.dispatchEvent(new CustomEvent('sidebar:select', { detail: { convId } }));
}
});
}
update() {
const list = this.el.querySelector('#conv-list');
const convs = store.getConversations();
const currentId = store.getCurrentConversationId();
if (convs.length === 0) {
list.innerHTML = '<div class="px-4 py-10 text-center text-[11px] leading-relaxed text-[var(--c-side-tx3)]">No conversations yet.<br>Start a new chat!</div>';
return;
}
const items = convs.map(conv => {
const active = conv.id === currentId;
return `
<div class="group relative flex items-center gap-2 rounded-xl cursor-pointer border px-3 py-2.5 transition-all ${active ? 'border-[var(--c-side-act-bd)] bg-[var(--c-side-act)]' : 'border-transparent hover:border-[var(--c-side-bd)] hover:bg-[var(--c-side-el)]'}" data-conv-id="${conv.id}" role="option" aria-selected="${active}">
<div class="flex-1 min-w-0">
<div class="text-[13px] truncate ${active ? 'font-medium text-[var(--c-side-tx)]' : 'text-[var(--c-side-tx2)]'}">${escapeHtml(conv.title || 'New Chat')}</div>
<div class="mt-0.5 truncate text-[11px] text-[var(--c-side-tx3)]">${relativeTime(conv.updatedAt || conv.createdAt)}</div>
</div>
<button class="delete-conv flex-shrink-0 p-1 rounded-md text-[var(--c-side-tx3)] opacity-0 transition-all group-hover:opacity-100 hover:bg-[var(--c-side-el-h)] hover:text-[var(--c-side-tx)]" data-conv-id="${conv.id}" aria-label="Delete conversation">
${icon('trash')}
</button>
</div>
`;
}).join('');
list.innerHTML = items;
}
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
|