mirror of
https://github.com/stackblitz/bolt.new
synced 2025-06-26 18:17:50 +00:00
feat: improved sidebar
improved the sidebar
This commit is contained in:
parent
502de4b7f4
commit
f41b0827f9
@ -5,9 +5,11 @@ import { type ChatHistoryItem } from '~/lib/persistence';
|
||||
interface HistoryItemProps {
|
||||
item: ChatHistoryItem;
|
||||
onDelete?: (event: React.UIEvent) => void;
|
||||
onRename?: (event: React.UIEvent) => void;
|
||||
onExport?: (event: React.UIEvent) => void;
|
||||
}
|
||||
|
||||
export function HistoryItem({ item, onDelete }: HistoryItemProps) {
|
||||
export function HistoryItem({ item, onDelete, onRename, onExport }: HistoryItemProps) {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const hoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@ -16,7 +18,6 @@ export function HistoryItem({ item, onDelete }: HistoryItemProps) {
|
||||
|
||||
function mouseEnter() {
|
||||
setHovering(true);
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
@ -42,17 +43,33 @@ export function HistoryItem({ item, onDelete }: HistoryItemProps) {
|
||||
>
|
||||
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
|
||||
{item.description}
|
||||
<div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 to-transparent w-10 flex justify-end group-hover:w-15 group-hover:from-45%">
|
||||
<div className="absolute right-0 z-1 top-0 bottom-0 bg-gradient-to-l from-bolt-elements-background-depth-2 group-hover:from-bolt-elements-background-depth-3 to-transparent w-10 flex justify-end group-hover:w-32 group-hover:from-45%">
|
||||
{hovering && (
|
||||
<div className="flex items-center p-1 text-bolt-elements-textSecondary hover:text-bolt-elements-item-contentDanger">
|
||||
<div className="flex items-center gap-1 p-1 text-bolt-elements-textSecondary">
|
||||
<button
|
||||
className="i-ph:pencil-simple scale-110 hover:text-bolt-elements-textPrimary"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onRename?.(event);
|
||||
}}
|
||||
title="Rename"
|
||||
/>
|
||||
<button
|
||||
className="i-ph:export scale-110 hover:text-bolt-elements-textPrimary"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onExport?.(event);
|
||||
}}
|
||||
title="Export as JSON"
|
||||
/>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
className="i-ph:trash scale-110"
|
||||
className="i-ph:trash scale-110 hover:text-bolt-elements-item-contentDanger"
|
||||
onClick={(event) => {
|
||||
// we prevent the default so we don't trigger the anchor above
|
||||
event.preventDefault();
|
||||
onDelete?.(event);
|
||||
}}
|
||||
title="Delete"
|
||||
/>
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
|
||||
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
|
||||
import { db, deleteById, getAll, chatId, type ChatHistoryItem } from '~/lib/persistence';
|
||||
import { db, deleteById, getAll, chatId, type ChatHistoryItem, setMessages } from '~/lib/persistence';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { logger } from '~/utils/logger';
|
||||
import { HistoryItem } from './HistoryItem';
|
||||
@ -31,13 +31,17 @@ const menuVariants = {
|
||||
},
|
||||
} satisfies Variants;
|
||||
|
||||
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
||||
type DialogContent =
|
||||
| { type: 'delete'; item: ChatHistoryItem }
|
||||
| { type: 'rename'; item: ChatHistoryItem }
|
||||
| null;
|
||||
|
||||
export function Menu() {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [list, setList] = useState<ChatHistoryItem[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
const loadEntries = useCallback(() => {
|
||||
if (db) {
|
||||
@ -68,6 +72,43 @@ export function Menu() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renameItem = useCallback(async (event: React.UIEvent, item: ChatHistoryItem, newDescription: string) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (db) {
|
||||
try {
|
||||
await setMessages(db, item.id, item.messages, item.urlId, newDescription);
|
||||
loadEntries();
|
||||
toast.success('Chat renamed successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to rename chat');
|
||||
logger.error(error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const exportItem = useCallback((event: React.UIEvent, item: ChatHistoryItem) => {
|
||||
event.preventDefault();
|
||||
|
||||
const exportData = {
|
||||
description: item.description,
|
||||
messages: item.messages,
|
||||
timestamp: item.timestamp
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `chat-${item.description || 'export'}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success('Chat exported successfully');
|
||||
}, []);
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogContent(null);
|
||||
};
|
||||
@ -102,24 +143,16 @@ export function Menu() {
|
||||
return (
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
initial="closed"
|
||||
animate={open ? 'open' : 'closed'}
|
||||
className="fixed top-0 bottom-0 w-[300px] bg-bolt-elements-background-depth-2 border-r border-bolt-elements-borderColor z-sidebar"
|
||||
variants={menuVariants}
|
||||
className="flex flex-col side-menu fixed top-0 w-[350px] h-full bg-bolt-elements-background-depth-2 border-r rounded-r-3xl border-bolt-elements-borderColor z-sidebar shadow-xl shadow-bolt-elements-sidebar-dropdownShadow text-sm"
|
||||
animate={open ? 'open' : 'closed'}
|
||||
initial="closed"
|
||||
>
|
||||
<div className="flex items-center h-[var(--header-height)]">{/* Placeholder */}</div>
|
||||
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
|
||||
<div className="p-4">
|
||||
<a
|
||||
href="/"
|
||||
className="flex gap-2 items-center bg-bolt-elements-sidebar-buttonBackgroundDefault text-bolt-elements-sidebar-buttonText hover:bg-bolt-elements-sidebar-buttonBackgroundHover rounded-md p-2 transition-theme"
|
||||
>
|
||||
<span className="inline-block i-bolt:chat scale-110" />
|
||||
Start new chat
|
||||
</a>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="sticky top-0 z-1 bg-bolt-elements-background-depth-2 p-4 pt-12 flex justify-between items-center border-b border-bolt-elements-borderColor">
|
||||
<div className="text-bolt-elements-textPrimary font-medium">History</div>
|
||||
</div>
|
||||
<div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div>
|
||||
<div className="flex-1 overflow-scroll pl-4 pr-5 pb-5">
|
||||
<div className="flex-1 overflow-y-auto p-2 pb-16">
|
||||
{list.length === 0 && <div className="pl-2 text-bolt-elements-textTertiary">No previous conversations</div>}
|
||||
<DialogRoot open={dialogContent !== null}>
|
||||
{binDates(list).map(({ category, items }) => (
|
||||
@ -128,7 +161,16 @@ export function Menu() {
|
||||
{category}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<HistoryItem key={item.id} item={item} onDelete={() => setDialogContent({ type: 'delete', item })} />
|
||||
<HistoryItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
onDelete={() => setDialogContent({ type: 'delete', item })}
|
||||
onRename={() => {
|
||||
setNewName(item.description || '');
|
||||
setDialogContent({ type: 'rename', item });
|
||||
}}
|
||||
onExport={(event) => exportItem(event, item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@ -160,12 +202,45 @@ export function Menu() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{dialogContent?.type === 'rename' && (
|
||||
<>
|
||||
<DialogTitle>Rename Chat</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full p-2 mt-2 text-bolt-elements-textPrimary bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor rounded-md focus:outline-none focus:border-bolt-elements-borderColorFocus"
|
||||
placeholder="Enter new name"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
<div className="px-5 pb-4 bg-bolt-elements-background-depth-2 flex gap-2 justify-end">
|
||||
<DialogButton type="secondary" onClick={closeDialog}>
|
||||
Cancel
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
type="primary"
|
||||
onClick={(event) => {
|
||||
if (newName.trim()) {
|
||||
renameItem(event, dialogContent.item, newName.trim());
|
||||
closeDialog();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</DialogButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
<div className="flex items-center border-t border-bolt-elements-borderColor p-4">
|
||||
<ThemeSwitch className="ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-4 right-4">
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
21
package-lock.json
generated
21
package-lock.json
generated
@ -23,6 +23,8 @@
|
||||
"@codemirror/search": "^6.5.8",
|
||||
"@codemirror/state": "^6.5.1",
|
||||
"@codemirror/view": "^6.36.2",
|
||||
"@iconify-json/ph": "^1.2.2",
|
||||
"@iconify-json/svg-spinners": "^1.2.2",
|
||||
"@nanostores/react": "^0.8.4",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
@ -3364,11 +3366,28 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify-json/ph": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@iconify-json/ph/-/ph-1.2.2.tgz",
|
||||
"integrity": "sha512-PgkEZNtqa8hBGjHXQa4pMwZa93hmfu8FUSjs/nv4oUU6yLsgv+gh9nu28Kqi8Fz9CCVu4hj1MZs9/60J57IzFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iconify/types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify-json/svg-spinners": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@iconify-json/svg-spinners/-/svg-spinners-1.2.2.tgz",
|
||||
"integrity": "sha512-DIErwfBWWzLfmAG2oQnbUOSqZhDxlXvr8941itMCrxQoMB0Hiv8Ww6Bln/zIgxwjDvSem2dKJtap+yKKwsB/2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iconify/types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@iconify/types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
||||
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@iconify/utils": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user