Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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)}
/>
Expand Down
17 changes: 17 additions & 0 deletions src/components/Icons.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,23 @@ export const StopIcon = () => (
</svg>
);

export const PencilIcon = () => (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);

export const TrashIcon = () => (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
);

export const MicIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="2" width="6" height="12" rx="3" />
Expand Down
65 changes: 65 additions & 0 deletions src/components/Sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
159 changes: 140 additions & 19 deletions src/components/Sidebar.jsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 && (
<div className="sidebar-backdrop" onClick={onToggleCollapse} />
)}

<aside className={`sidebar${collapsed ? " collapsed" : ""}`}>
{/* Header */}
<div className="sidebar-header">
<button
className="sidebar-toggle"
onClick={onToggleCollapse}
aria-label="Toggle sidebar"
>
<button className="sidebar-toggle" onClick={onToggleCollapse} aria-label="Toggle sidebar">
<HamburgerIcon />
</button>
<button className="sidebar-new-btn" onClick={onNewConversation}>
Expand All @@ -34,26 +107,41 @@ export default function Sidebar({
</button>
</div>

{/* Conversation list */}
<div className="sidebar-conversations">
{conversations.length === 0 ? (
<p className="sidebar-empty">No conversations yet</p>
) : (
conversations.map((convo) => (
<button
key={convo.id}
className={`sidebar-convo-btn${convo.id === activeConversationId ? " active" : ""}`}
onClick={() => onSelectConversation(convo.id)}
title={convo.title}
>
<ChatIcon />
<span className="sidebar-convo-title">{convo.title}</span>
</button>
editingId === convo.id ? (
<div key={convo.id} className="sidebar-convo-edit">
<input
ref={editInputRef}
className="sidebar-convo-input"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleEditKeyDown}
/>
</div>
) : (
<button
key={convo.id}
className={`sidebar-convo-btn${convo.id === activeConversationId ? " active" : ""}`}
onClick={() => handleConvoClick(convo.id)}
onContextMenu={(e) => handleContextMenu(e, convo.id)}
onTouchStart={(e) => handleTouchStart(e, convo.id)}
onTouchEnd={handleTouchEnd}
onTouchMove={handleTouchMove}
title={convo.title}
>
<ChatIcon />
<span className="sidebar-convo-title">{convo.title}</span>
</button>
)
))
)}
</div>

{/* Footer */}
<div className="sidebar-footer">
<button className="sidebar-footer-btn" onClick={onOpenSettings}>
<GearIcon />
Expand All @@ -65,6 +153,39 @@ export default function Sidebar({
</button>
</div>
</aside>

{menu.visible && (
<div
ref={menuRef}
className="convo-context-menu"
style={{ left: menu.x, top: menu.y }}
>
<button
className="convo-context-item"
onClick={() => {
const convo = conversations.find((c) => c.id === menu.convoId);
if (convo) startEdit(convo.id, convo.title);
}}
>
<PencilIcon />
<span>Edit name</span>
</button>
<button
className="convo-context-item"
onClick={() => { closeMenu(); onExportConversation(menu.convoId); }}
>
<DownloadIcon />
<span>Export</span>
</button>
<button
className="convo-context-item convo-context-item--danger"
onClick={() => { closeMenu(); onDeleteConversation(menu.convoId); }}
>
<TrashIcon />
<span>Delete</span>
</button>
</div>
)}
</>
);
}