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 {
|
interface HistoryItemProps {
|
||||||
item: ChatHistoryItem;
|
item: ChatHistoryItem;
|
||||||
onDelete?: (event: React.UIEvent) => void;
|
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 [hovering, setHovering] = useState(false);
|
||||||
const hoverRef = useRef<HTMLDivElement>(null);
|
const hoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -16,7 +18,6 @@ export function HistoryItem({ item, onDelete }: HistoryItemProps) {
|
|||||||
|
|
||||||
function mouseEnter() {
|
function mouseEnter() {
|
||||||
setHovering(true);
|
setHovering(true);
|
||||||
|
|
||||||
if (timeout) {
|
if (timeout) {
|
||||||
clearTimeout(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">
|
<a href={`/chat/${item.urlId}`} className="flex w-full relative truncate block">
|
||||||
{item.description}
|
{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 && (
|
{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>
|
<Dialog.Trigger asChild>
|
||||||
<button
|
<button
|
||||||
className="i-ph:trash scale-110"
|
className="i-ph:trash scale-110 hover:text-bolt-elements-item-contentDanger"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
// we prevent the default so we don't trigger the anchor above
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onDelete?.(event);
|
onDelete?.(event);
|
||||||
}}
|
}}
|
||||||
|
title="Delete"
|
||||||
/>
|
/>
|
||||||
</Dialog.Trigger>
|
</Dialog.Trigger>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
|
|||||||
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
||||||
import { IconButton } from '~/components/ui/IconButton';
|
import { IconButton } from '~/components/ui/IconButton';
|
||||||
import { ThemeSwitch } from '~/components/ui/ThemeSwitch';
|
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 { cubicEasingFn } from '~/utils/easings';
|
||||||
import { logger } from '~/utils/logger';
|
import { logger } from '~/utils/logger';
|
||||||
import { HistoryItem } from './HistoryItem';
|
import { HistoryItem } from './HistoryItem';
|
||||||
@ -31,13 +31,17 @@ const menuVariants = {
|
|||||||
},
|
},
|
||||||
} satisfies Variants;
|
} satisfies Variants;
|
||||||
|
|
||||||
type DialogContent = { type: 'delete'; item: ChatHistoryItem } | null;
|
type DialogContent =
|
||||||
|
| { type: 'delete'; item: ChatHistoryItem }
|
||||||
|
| { type: 'rename'; item: ChatHistoryItem }
|
||||||
|
| null;
|
||||||
|
|
||||||
export function Menu() {
|
export function Menu() {
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const [list, setList] = useState<ChatHistoryItem[]>([]);
|
const [list, setList] = useState<ChatHistoryItem[]>([]);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
|
const [dialogContent, setDialogContent] = useState<DialogContent>(null);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
|
||||||
const loadEntries = useCallback(() => {
|
const loadEntries = useCallback(() => {
|
||||||
if (db) {
|
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 = () => {
|
const closeDialog = () => {
|
||||||
setDialogContent(null);
|
setDialogContent(null);
|
||||||
};
|
};
|
||||||
@ -102,24 +143,16 @@ export function Menu() {
|
|||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
initial="closed"
|
className="fixed top-0 bottom-0 w-[300px] bg-bolt-elements-background-depth-2 border-r border-bolt-elements-borderColor z-sidebar"
|
||||||
animate={open ? 'open' : 'closed'}
|
|
||||||
variants={menuVariants}
|
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="h-full flex flex-col">
|
||||||
<div className="flex-1 flex flex-col h-full w-full overflow-hidden">
|
<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="p-4">
|
<div className="text-bolt-elements-textPrimary font-medium">History</div>
|
||||||
<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>
|
</div>
|
||||||
<div className="text-bolt-elements-textPrimary font-medium pl-6 pr-5 my-2">Your Chats</div>
|
<div className="flex-1 overflow-y-auto p-2 pb-16">
|
||||||
<div className="flex-1 overflow-scroll pl-4 pr-5 pb-5">
|
|
||||||
{list.length === 0 && <div className="pl-2 text-bolt-elements-textTertiary">No previous conversations</div>}
|
{list.length === 0 && <div className="pl-2 text-bolt-elements-textTertiary">No previous conversations</div>}
|
||||||
<DialogRoot open={dialogContent !== null}>
|
<DialogRoot open={dialogContent !== null}>
|
||||||
{binDates(list).map(({ category, items }) => (
|
{binDates(list).map(({ category, items }) => (
|
||||||
@ -128,7 +161,16 @@ export function Menu() {
|
|||||||
{category}
|
{category}
|
||||||
</div>
|
</div>
|
||||||
{items.map((item) => (
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -160,12 +202,45 @@ export function Menu() {
|
|||||||
</div>
|
</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>
|
</Dialog>
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
21
package-lock.json
generated
21
package-lock.json
generated
@ -23,6 +23,8 @@
|
|||||||
"@codemirror/search": "^6.5.8",
|
"@codemirror/search": "^6.5.8",
|
||||||
"@codemirror/state": "^6.5.1",
|
"@codemirror/state": "^6.5.1",
|
||||||
"@codemirror/view": "^6.36.2",
|
"@codemirror/view": "^6.36.2",
|
||||||
|
"@iconify-json/ph": "^1.2.2",
|
||||||
|
"@iconify-json/svg-spinners": "^1.2.2",
|
||||||
"@nanostores/react": "^0.8.4",
|
"@nanostores/react": "^0.8.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
@ -3364,11 +3366,28 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"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": {
|
"node_modules/@iconify/types": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
||||||
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
|
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@iconify/utils": {
|
"node_modules/@iconify/utils": {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user