SPB Git

spb/chat-spboucher Public

Private universal chat interface over the OpenRouter ecosystem — 400+ models, branching, streaming, usage tracking. Next.js 16 + SQLite, PWA, deployed on m4m64a at chat.spboucher.ai

TypeScript 78.8% CSS 15.1% JavaScript 4.9% Shell 1.2%
3.5 KB · 94 lines tsx
Raw Blame History
1// Author: Simon-Pierre Boucher2// Contact: contact@spboucher.ai3// Project: chat.spboucher.ai45"use client";67import { useRouter } from "next/navigation";8import type { ApiConversation } from "./types";910interface SidebarProps {11  conversations: ApiConversation[];12  activeId: string | null;13  open: boolean;14  onSelect: (id: string) => void;15  onNew: () => void;16  onDelete: (id: string) => void;17  onClose: () => void;18}1920export function Sidebar({ conversations, activeId, open, onSelect, onNew, onDelete, onClose }: SidebarProps) {21  const router = useRouter();2223  async function logout() {24    await fetch("/api/auth/logout", { method: "POST" });25    router.replace("/login");26    router.refresh();27  }2829  return (30    <>31      <div className={`drawer-scrim${open ? " open" : ""}`} onClick={onClose} aria-hidden />32      <aside className={`sidebar${open ? " open" : ""}`}>33        <div className="sidebar-header">34          <span className="wordmark">35            chat.spboucher<span className="tld">.ai</span>36          </span>37        </div>38        <button className="new-chat-btn" onClick={onNew}>39          <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6">40            <path d="M8 3v10M3 8h10" strokeLinecap="round" />41          </svg>42          New conversation43        </button>44        <nav className="conv-list">45          {conversations.map((c) => (46            <div47              key={c.id}48              className={`conv-item${c.id === activeId ? " active" : ""}`}49              role="button"50              tabIndex={0}51              onClick={() => onSelect(c.id)}52              onKeyDown={(e) => e.key === "Enter" && onSelect(c.id)}53            >54              <span className="conv-title">{c.title}</span>55              <button56                className="conv-del"57                aria-label={`Delete "${c.title}"`}58                onClick={(e) => {59                  e.stopPropagation();60                  if (confirm(`Delete "${c.title}"? This cannot be undone.`)) onDelete(c.id);61                }}62              >63                <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">64                  <path d="M2 3.5h10M5.5 3.5V2h3v1.5M3.5 3.5l.7 8a1 1 0 0 0 1 .9h3.6a1 1 0 0 0 1-.9l.7-8" strokeLinecap="round" />65                </svg>66              </button>67            </div>68          ))}69          {conversations.length === 0 && (70            <p style={{ padding: "10px", fontSize: 13, color: "var(--text-dim)" }}>71              No conversations yet.72            </p>73          )}74        </nav>75        <div className="sidebar-footer">76          <a className="foot-btn" href="/settings" style={{ textDecoration: "none" }}>77            <svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4">78              <circle cx="7.5" cy="7.5" r="2.2" />79              <path d="M7.5 1.5v2M7.5 11.5v2M1.5 7.5h2M11.5 7.5h2M3.3 3.3l1.4 1.4M10.3 10.3l1.4 1.4M11.7 3.3l-1.4 1.4M4.7 10.3l-1.4 1.4" strokeLinecap="round" />80            </svg>81            Settings82          </a>83          <button className="foot-btn" onClick={logout}>84            <svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4">85              <path d="M9.5 2H4a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h5.5M12.5 7.5H6.5M10.5 5l2.5 2.5L10.5 10" strokeLinecap="round" />86            </svg>87            Sign out88          </button>89        </div>90      </aside>91    </>92  );93}94