From eb810a2736abe1d7e10ad013984059fa02675b03 Mon Sep 17 00:00:00 2001 From: phillsatellite Date: Mon, 9 Mar 2026 23:17:33 -0400 Subject: [PATCH] Add sidebar context menu with rename, export, and delete Right-click or tap+hold on any conversation to open a context menu with Edit name (inline rename), Export (opens export modal), and Delete options. Menu position is clamped to viewport bounds on all screen sizes. --- src/App.jsx | 36 ++++++++- src/components/Icons.jsx | 17 ++++ src/components/Sidebar.css | 65 +++++++++++++++ src/components/Sidebar.jsx | 159 ++++++++++++++++++++++++++++++++----- 4 files changed, 257 insertions(+), 20 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 0c3c978..2c9e45e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { onAuthStateChanged, signOut } from "firebase/auth"; -import { doc, getDoc, setDoc, collection, query, orderBy, onSnapshot } from "firebase/firestore"; +import { doc, getDoc, setDoc, deleteDoc, collection, query, orderBy, onSnapshot } from "firebase/firestore"; import { auth, db } from "./utils/firebase"; import Welcome from "./components/Welcome.jsx"; import AuthScreen from "./components/AuthScreen.jsx"; @@ -165,6 +165,37 @@ export default function App() { } }; + const handleRenameConversation = async (id, newTitle) => { + if (!user?.uid) return; + try { + await setDoc( + doc(db, "users", user.uid, "conversations", id), + { title: newTitle, updatedAt: new Date().toISOString() }, + { merge: true } + ); + } catch {} + }; + + const handleExportConversation = (id) => { + const convo = conversations.find((c) => c.id === id); + if (convo) { + setOutput(convo.text); + setActiveConversationId(id); + setShowExportModal(true); + } + }; + + const handleDeleteConversation = async (id) => { + if (!user?.uid) return; + try { + await deleteDoc(doc(db, "users", user.uid, "conversations", id)); + } catch {} + if (activeConversationId === id) { + setActiveConversationId(null); + setOutput(""); + } + }; + const isMainApp = !authLoading && !showWelcome && !!user && !!apiKey; const hero = ( @@ -224,6 +255,9 @@ export default function App() { onNewConversation={handleNewConversation} onOpenSettings={() => setShowSettingsModal(true)} onSignOut={handleSignOut} + onRenameConversation={handleRenameConversation} + onExportConversation={handleExportConversation} + onDeleteConversation={handleDeleteConversation} collapsed={sidebarCollapsed} onToggleCollapse={() => setSidebarCollapsed((c) => !c)} /> diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx index 8f78e48..152d74a 100644 --- a/src/components/Icons.jsx +++ b/src/components/Icons.jsx @@ -144,6 +144,23 @@ export const StopIcon = () => ( ); +export const PencilIcon = () => ( + + + + +); + +export const TrashIcon = () => ( + + + + + + + +); + export const MicIcon = () => ( diff --git a/src/components/Sidebar.css b/src/components/Sidebar.css index b77180c..5f76680 100644 --- a/src/components/Sidebar.css +++ b/src/components/Sidebar.css @@ -166,6 +166,71 @@ color: var(--text); } +/* ── Inline rename input ── */ +.sidebar-convo-edit { + padding: 2px 8px; +} + +.sidebar-convo-input { + width: 100%; + padding: 8px 10px; + border-radius: var(--radius-sm); + border: 1px solid var(--accent); + background: var(--surface2); + color: var(--text); + font-family: 'Syne', sans-serif; + font-size: 13px; + outline: none; + box-sizing: border-box; +} + +/* ── Context menu ── */ +.convo-context-menu { + position: fixed; + z-index: 2000; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + min-width: 164px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.convo-context-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--text-muted); + font-family: 'Syne', sans-serif; + font-size: 13px; + cursor: pointer; + width: 100%; + text-align: left; + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} + +.convo-context-item:hover { + background: var(--surface2); + color: var(--text); +} + +.convo-context-item--danger { + color: #e05555; +} + +.convo-context-item--danger:hover { + background: rgba(224, 85, 85, 0.1); + color: #e05555; +} + /* ── Mobile ── */ @media (max-width: 768px) { .sidebar { diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 4f788ec..f402af7 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -1,4 +1,5 @@ -import { PlusIcon, HamburgerIcon, GearIcon, SignOutIcon, ChatIcon } from "./Icons"; +import { useState, useRef, useEffect } from "react"; +import { PlusIcon, HamburgerIcon, GearIcon, SignOutIcon, ChatIcon, PencilIcon, DownloadIcon, TrashIcon } from "./Icons"; import "./Sidebar.css"; export default function Sidebar({ @@ -8,24 +9,96 @@ export default function Sidebar({ onNewConversation, onOpenSettings, onSignOut, + onRenameConversation, + onExportConversation, + onDeleteConversation, collapsed, onToggleCollapse, }) { + const [menu, setMenu] = useState({ visible: false, x: 0, y: 0, convoId: null }); + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(""); + const longPressTimer = useRef(null); + const didLongPress = useRef(false); + const menuRef = useRef(null); + const editInputRef = useRef(null); + + // Close menu on outside click + useEffect(() => { + if (!menu.visible) return; + const handler = (e) => { + if (menuRef.current && !menuRef.current.contains(e.target)) { + setMenu((m) => ({ ...m, visible: false })); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [menu.visible]); + + // Focus input when editing starts + useEffect(() => { + if (editingId && editInputRef.current) editInputRef.current.focus(); + }, [editingId]); + + const showMenu = (x, y, convoId) => { + const menuWidth = 164; + const menuHeight = 124; + const clampedX = x + menuWidth > window.innerWidth ? window.innerWidth - menuWidth - 8 : x; + const clampedY = y + menuHeight > window.innerHeight ? window.innerHeight - menuHeight - 8 : y; + setMenu({ visible: true, x: clampedX, y: clampedY, convoId }); + }; + + const handleContextMenu = (e, convoId) => { + e.preventDefault(); + showMenu(e.clientX, e.clientY, convoId); + }; + + const handleTouchStart = (e, convoId) => { + const touch = e.touches[0]; + const x = touch.clientX; + const y = touch.clientY; + didLongPress.current = false; + longPressTimer.current = setTimeout(() => { + didLongPress.current = true; + showMenu(x, y, convoId); + }, 600); + }; + + const handleTouchEnd = () => clearTimeout(longPressTimer.current); + const handleTouchMove = () => clearTimeout(longPressTimer.current); + + const handleConvoClick = (convoId) => { + if (didLongPress.current) { didLongPress.current = false; return; } + onSelectConversation(convoId); + }; + + const closeMenu = () => setMenu((m) => ({ ...m, visible: false })); + + const startEdit = (convoId, currentTitle) => { + closeMenu(); + setEditingId(convoId); + setEditValue(currentTitle); + }; + + const commitEdit = () => { + if (editValue.trim()) onRenameConversation(editingId, editValue.trim()); + setEditingId(null); + }; + + const handleEditKeyDown = (e) => { + if (e.key === "Enter") commitEdit(); + if (e.key === "Escape") setEditingId(null); + }; + return ( <> - {/* Mobile backdrop */} {!collapsed && (
)} + + {menu.visible && ( +
+ + + +
+ )} ); }