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:
commit
52a6108791
@ -1,22 +1,27 @@
|
|||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { type ChatHistoryItem } from '~/lib/persistence';
|
import { type ChatHistoryItem } from '~/lib/persistence';
|
||||||
|
|
||||||
interface HistoryItemProps {
|
interface HistoryItemProps {
|
||||||
item: ChatHistoryItem;
|
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 [hovering, setHovering] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedDescription, setEditedDescription] = useState(item.description || '');
|
||||||
const hoverRef = useRef<HTMLDivElement>(null);
|
const hoverRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timeout: NodeJS.Timeout | undefined;
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
function mouseEnter() {
|
function mouseEnter() {
|
||||||
setHovering(true);
|
setHovering(true);
|
||||||
|
|
||||||
if (timeout) {
|
if (timeout) {
|
||||||
clearTimeout(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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={hoverRef}
|
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"
|
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">
|
{isEditing ? (
|
||||||
{item.description}
|
<input
|
||||||
<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%">
|
ref={inputRef}
|
||||||
{hovering && (
|
type="text"
|
||||||
<div className="flex items-center p-1 text-bolt-elements-textSecondary hover:text-bolt-elements-item-contentDanger">
|
value={editedDescription}
|
||||||
<Dialog.Trigger asChild>
|
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
|
<button
|
||||||
className="i-ph:trash scale-110"
|
className="i-ph:pencil-simple scale-110 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
||||||
onClick={(event) => {
|
onClick={(event: MouseEvent) => {
|
||||||
// we prevent the default so we don't trigger the anchor above
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onDelete?.(event);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Dialog.Trigger>
|
<button
|
||||||
</div>
|
className="i-ph:export scale-110 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
|
||||||
)}
|
onClick={(event: MouseEvent) => {
|
||||||
</div>
|
event.preventDefault();
|
||||||
</a>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { motion, type Variants } from 'framer-motion';
|
import { motion, type Variants } from 'framer-motion';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
import { Dialog, DialogButton, DialogDescription, DialogRoot, DialogTitle } from '~/components/ui/Dialog';
|
||||||
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';
|
||||||
@ -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 = () => {
|
const closeDialog = () => {
|
||||||
setDialogContent(null);
|
setDialogContent(null);
|
||||||
};
|
};
|
||||||
@ -127,7 +162,13 @@ 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={renameItem}
|
||||||
|
onExport={exportItem}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user