mirror of
https://github.com/stackblitz/bolt.new
synced 2025-06-26 18:17:50 +00:00
feat: improved sidebar options
added the export and rename options to each history item
This commit is contained in:
parent
acf2ac6587
commit
9f1558f0fd
@ -1,22 +1,27 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { type ChatHistoryItem } from '~/lib/persistence';
|
||||
|
||||
interface HistoryItemProps {
|
||||
item: ChatHistoryItem;
|
||||
onDelete?: (event: React.UIEvent) => void;
|
||||
onDelete?: (event: MouseEvent) => void;
|
||||
onRename?: (id: string, newDescription: string) => void;
|
||||
onExport?: (item: ChatHistoryItem) => void;
|
||||
}
|
||||
|
||||
export function HistoryItem({ item, onDelete }: HistoryItemProps) {
|
||||
export function HistoryItem({ item, onDelete, onRename, onExport }: HistoryItemProps) {
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedDescription, setEditedDescription] = useState(item.description || '');
|
||||
const hoverRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function mouseEnter() {
|
||||
setHovering(true);
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
@ -35,30 +40,77 @@ export function HistoryItem({ item, onDelete }: HistoryItemProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleRename = () => {
|
||||
if (editedDescription.trim() && onRename) {
|
||||
onRename(item.id, editedDescription.trim());
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
setEditedDescription(item.description || '');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hoverRef}
|
||||
className="group rounded-md text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-background-depth-3 overflow-hidden flex justify-between items-center px-2 py-1"
|
||||
>
|
||||
<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%">
|
||||
{hovering && (
|
||||
<div className="flex items-center p-1 text-bolt-elements-textSecondary hover:text-bolt-elements-item-contentDanger">
|
||||
<Dialog.Trigger asChild>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editedDescription}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setEditedDescription(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 bg-transparent border border-bolt-elements-borderColor rounded px-2 py-1 text-bolt-elements-textPrimary focus:outline-none focus:border-bolt-elements-borderColorFocus"
|
||||
/>
|
||||
) : (
|
||||
<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-24 group-hover:from-45%">
|
||||
{hovering && (
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<button
|
||||
className="i-ph:trash scale-110"
|
||||
onClick={(event) => {
|
||||
// we prevent the default so we don't trigger the anchor above
|
||||
className="i-ph:pencil-simple scale-110 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
onDelete?.(event);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
/>
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
className="i-ph:export scale-110 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
onExport?.(item);
|
||||
}}
|
||||
/>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
className="i-ph:trash scale-110 text-bolt-elements-textSecondary hover:text-bolt-elements-item-contentDanger"
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
onDelete?.(event);
|
||||
}}
|
||||
/>
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { motion, type Variants } from 'framer-motion';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
||||
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';
|
||||
@ -67,6 +68,40 @@ export function Menu() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renameItem = useCallback((id: string, newDescription: string) => {
|
||||
if (db) {
|
||||
const item = list.find((item) => item.id === id);
|
||||
if (item) {
|
||||
setMessages(db, id, item.messages, item.urlId, newDescription)
|
||||
.then(() => {
|
||||
loadEntries();
|
||||
toast.success('Chat renamed successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('Failed to rename chat');
|
||||
logger.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [list]);
|
||||
|
||||
const exportItem = useCallback((item: ChatHistoryItem) => {
|
||||
try {
|
||||
const chatData = {
|
||||
description: item.description,
|
||||
messages: item.messages,
|
||||
timestamp: item.timestamp,
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(chatData, null, 2)], { type: 'application/json' });
|
||||
const filename = `${item.description || 'chat'}-${new Date(item.timestamp).toISOString().split('T')[0]}.json`;
|
||||
saveAs(blob, filename);
|
||||
toast.success('Chat exported successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to export chat');
|
||||
logger.error(error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogContent(null);
|
||||
};
|
||||
@ -127,7 +162,13 @@ 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={renameItem}
|
||||
onExport={exportItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user