SPB Git

spb/zyquo-cloud-web Public MIT

Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.

TypeScript 81.9% CSS 8.9% JavaScript 7.5% Shell 1.1% HTML 0.6%
6.5 KB · 180 lines tsx
Raw Blame History
1/*2 *  Sidebar.tsx3 *  Zyquo Cloud Web4 *5 *  Author: Simon-Pierre Boucher6 *  Mail: contact@spboucher.ai7 *8 *  Conversation sidebar: wordmark, search (⌘F), New Chat, grouped rows9 *  (Pinned / Today / Yesterday / Previous 7 Days / Older), footer with10 *  Settings + usage summary. Collapses to a drawer on phones.11 */1213import { useMemo, useRef } from 'react'14import { useStore } from '../state/store'15import { matchesSearch } from '../storage/conversations'16import type { Conversation } from '../types'17import ZyquoGlyph from './ZyquoGlyph'18import { IconGear, IconPin, IconPlus, IconSearch, IconTrash } from './icons'1920const GROUP_ORDER = ['Pinned', 'Today', 'Yesterday', 'Previous 7 Days', 'Older'] as const2122function groupFor(conversation: Conversation, now: Date): (typeof GROUP_ORDER)[number] {23  if (conversation.pinned) return 'Pinned'24  const updated = new Date(conversation.updatedAt)25  const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()26  if (updated.getTime() >= startOfToday) return 'Today'27  if (updated.getTime() >= startOfToday - 86_400_000) return 'Yesterday'28  if (updated.getTime() >= startOfToday - 7 * 86_400_000) return 'Previous 7 Days'29  return 'Older'30}3132function relativeTime(timestamp: number): string {33  const delta = Date.now() - timestamp34  const minutes = Math.floor(delta / 60_000)35  if (minutes < 1) return 'now'36  if (minutes < 60) return `${minutes}m`37  const hours = Math.floor(minutes / 60)38  if (hours < 24) return `${hours}h`39  const days = Math.floor(hours / 24)40  if (days < 7) return `${days}d`41  return new Date(timestamp).toLocaleDateString()42}4344export default function Sidebar() {45  const conversations = useStore((s) => s.conversations)46  const selectedID = useStore((s) => s.selectedID)47  const searchText = useStore((s) => s.searchText)48  const setSearchText = useStore((s) => s.setSearchText)49  const select = useStore((s) => s.select)50  const newConversation = useStore((s) => s.newConversation)51  const removeConversation = useStore((s) => s.removeConversation)52  const updateConversation = useStore((s) => s.updateConversation)53  const openSettings = useStore((s) => s.openSettings)54  const sidebarOpen = useStore((s) => s.sidebarOpen)55  const searchRef = useRef<HTMLInputElement>(null)5657  const groups = useMemo(() => {58    const now = new Date()59    const filtered = conversations.filter((c) => matchesSearch(c, searchText))60    const byGroup = new Map<string, Conversation[]>()61    for (const conversation of filtered) {62      const group = groupFor(conversation, now)63      const list = byGroup.get(group) ?? []64      list.push(conversation)65      byGroup.set(group, list)66    }67    return GROUP_ORDER.filter((g) => byGroup.has(g)).map((g) => ({68      name: g,69      items: byGroup.get(g) ?? [],70    }))71  }, [conversations, searchText])7273  const usage = useMemo(() => {74    let tokens = 075    let cost = 076    for (const conversation of conversations) {77      for (const message of conversation.messages) {78        tokens += (message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0)79        cost += message.estimatedCost ?? 080      }81    }82    return { tokens, cost }83  }, [conversations])8485  return (86    <aside className={`sidebar${sidebarOpen ? ' open' : ''}`}>87      <div className="sidebar-wordmark">88        <ZyquoGlyph size={20} />89        <span>Zyquo Cloud</span>90      </div>91      <div className="sidebar-search">92        <IconSearch />93        <input94          ref={searchRef}95          data-search-input96          placeholder="Search"97          value={searchText}98          onChange={(e) => setSearchText(e.target.value)}99        />100      </div>101      <button className="new-chat-button" onClick={() => newConversation()}>102        <IconPlus size={12} /> New Chat103      </button>104      <div className="conversation-list">105        {groups.map((group) => (106          <div key={group.name}>107            <div className="conversation-group-header">{group.name}</div>108            {group.items.map((conversation) => (109              <div110                key={conversation.id}111                role="button"112                tabIndex={0}113                className={`conversation-row${conversation.id === selectedID ? ' selected' : ''}`}114                onClick={() => select(conversation.id)}115                onKeyDown={(e) => e.key === 'Enter' && select(conversation.id)}116              >117                <div className="conversation-row-main">118                  <div className="conversation-row-title">{conversation.title}</div>119                  <div className="conversation-row-sub">120                    {conversation.modelID} · {relativeTime(conversation.updatedAt)}121                  </div>122                </div>123                {conversation.pinned && (124                  <span className="pin-indicator">125                    <IconPin size={9} />126                  </span>127                )}128                <span className="conversation-row-actions">129                  <button130                    className="icon-button"131                    title={conversation.pinned ? 'Unpin' : 'Pin'}132                    onClick={(e) => {133                      e.stopPropagation()134                      updateConversation(conversation.id, { pinned: !conversation.pinned })135                    }}136                  >137                    <IconPin size={10} />138                  </button>139                  <button140                    className="icon-button danger"141                    title="Delete"142                    onClick={(e) => {143                      e.stopPropagation()144                      removeConversation(conversation.id)145                    }}146                  >147                    <IconTrash size={10} />148                  </button>149                </span>150              </div>151            ))}152          </div>153        ))}154        {groups.length === 0 && (155          <div className="panel-empty">156            {searchText ? 'No conversations match.' : 'No conversations yet.'}157          </div>158        )}159      </div>160      <div className="sidebar-footer">161        <button className="icon-button" title="Settings (⌘,)" onClick={() => openSettings()}>162          <IconGear />163        </button>164        {usage.tokens > 0 && (165          <span>166            {usage.tokens >= 1_000_000167              ? `${(usage.tokens / 1_000_000).toFixed(1)}M tok`168              : `${(usage.tokens / 1_000).toFixed(1)}K tok`}169            {usage.cost >= 0.01170              ? ` · ~$${usage.cost.toFixed(2)}`171              : usage.cost > 0172                ? ' · <$0.01'173                : ''}174          </span>175        )}176      </div>177    </aside>178  )179}180