This commit is contained in:
Zacharias-Brohn
2026-01-14 21:45:28 +01:00
parent 2d68b5bff7
commit c6352f4a19
5 changed files with 372 additions and 25 deletions
+93
View File
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
import { prisma } from '@/lib/prisma';
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || 'your-secret-key-at-least-32-chars-long'
);
interface RouteParams {
params: Promise<{ id: string }>;
}
// PATCH /api/chats/[id] - Update chat (rename, pin)
export async function PATCH(request: NextRequest, { params }: RouteParams) {
const token = request.cookies.get('token')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
const userId = payload.userId as string;
const { id: chatId } = await params;
const body = await request.json();
// Verify chat belongs to user
const chat = await prisma.chat.findFirst({
where: { id: chatId, userId },
});
if (!chat) {
return NextResponse.json({ error: 'Chat not found' }, { status: 404 });
}
// Build update object
const updateData: { title?: string; pinned?: boolean } = {};
if (typeof body.title === 'string') {
updateData.title = body.title;
}
if (typeof body.pinned === 'boolean') {
updateData.pinned = body.pinned;
}
const updatedChat = await prisma.chat.update({
where: { id: chatId },
data: updateData,
});
return NextResponse.json(updatedChat, { status: 200 });
} catch (error) {
console.error('Update chat error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
// DELETE /api/chats/[id] - Delete chat
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const token = request.cookies.get('token')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
const userId = payload.userId as string;
const { id: chatId } = await params;
// Verify chat belongs to user
const chat = await prisma.chat.findFirst({
where: { id: chatId, userId },
});
if (!chat) {
return NextResponse.json({ error: 'Chat not found' }, { status: 404 });
}
// Delete messages first (cascade), then chat
await prisma.message.deleteMany({
where: { chatId },
});
await prisma.chat.delete({
where: { id: chatId },
});
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error('Delete chat error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ export async function GET(request: NextRequest) {
const chats = await prisma.chat.findMany({
where: { userId },
orderBy: { updatedAt: 'desc' },
orderBy: [{ pinned: 'desc' }, { updatedAt: 'desc' }],
include: {
messages: {
orderBy: { createdAt: 'asc' },
+207 -24
View File
@@ -2,12 +2,16 @@
import { useEffect, useRef, useState } from 'react';
import {
IconDotsVertical,
IconLayoutSidebar,
IconMessage,
IconPencil,
IconPin,
IconPlus,
IconRobot,
IconSend,
IconSettings,
IconTrash,
IconUser,
} from '@tabler/icons-react';
import {
@@ -17,6 +21,7 @@ import {
Burger,
Container,
Group,
Menu,
Paper,
ScrollArea,
Select,
@@ -35,10 +40,14 @@ import { useThemeContext } from '@/components/DynamicThemeProvider';
import { SettingsModal } from '@/components/Settings/SettingsModal';
import {
addMessageToLocalChat,
deleteLocalChat,
deleteScrollPosition,
getLocalChat,
getLocalChats,
getScrollPosition,
mergeChats,
saveLocalChat,
saveScrollPosition,
setLocalChats,
} from '@/lib/chatStorage';
import { MarkdownMessage } from './MarkdownMessage';
@@ -55,6 +64,7 @@ interface Chat {
title: string;
updatedAt: string;
messages?: Message[];
pinned?: boolean;
}
export function InputWithButton(props: TextInputProps) {
@@ -107,18 +117,24 @@ export default function ChatLayout() {
const isUserScrolledUp = useRef(false);
const isStreaming = useRef(false);
// Handle scroll events to track if user scrolled up (only when not streaming)
// Handle scroll events - save position and track if user scrolled up
const handleScroll = () => {
if (isStreaming.current) {
return; // Ignore scroll position checks during streaming
}
const viewport = scrollViewportRef.current;
if (!viewport) {
return;
}
const threshold = 50;
isUserScrolledUp.current =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight > threshold;
// Save scroll position for current chat
if (activeChatId) {
saveScrollPosition(activeChatId, viewport.scrollTop);
}
// Track if user scrolled up (only when not streaming)
if (!isStreaming.current) {
const threshold = 50;
isUserScrolledUp.current =
viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight > threshold;
}
};
// Scroll to bottom using CSS scroll-behavior for smooth animation
@@ -129,6 +145,24 @@ export default function ChatLayout() {
}
};
// Restore scroll position for a chat
const restoreScrollPosition = (chatId: string) => {
setTimeout(() => {
const viewport = scrollViewportRef.current;
if (!viewport) {
return;
}
const savedPosition = getScrollPosition(chatId);
if (savedPosition !== null) {
viewport.scrollTop = savedPosition;
} else {
// New chat or no saved position - scroll to bottom
viewport.scrollTop = viewport.scrollHeight;
}
}, 0);
};
// Auto-scroll when messages change (during streaming)
useEffect(() => {
if (streamingMessageId) {
@@ -206,13 +240,9 @@ export default function ChatLayout() {
if (mobileOpened) {
toggleMobile();
}
// Scroll to bottom after messages load
setTimeout(() => {
const viewport = scrollViewportRef.current;
if (viewport) {
viewport.scrollTop = viewport.scrollHeight;
}
}, 0);
// Restore saved scroll position
restoreScrollPosition(chat.id);
};
const handleNewChat = () => {
@@ -367,6 +397,113 @@ export default function ChatLayout() {
}
};
// Chat menu handlers
const handleRenameChat = async (chatId: string) => {
const chat = chats.find((c) => c.id === chatId);
if (!chat) {
return;
}
const newTitle = window.prompt('Enter new chat title:', chat.title);
if (!newTitle || newTitle === chat.title) {
return;
}
try {
// Update in database
await fetch(`/api/chats/${chatId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle }),
});
// Update local state
setChats((prev) => prev.map((c) => (c.id === chatId ? { ...c, title: newTitle } : c)));
// Update localStorage
const localChat = getLocalChat(chatId);
if (localChat) {
saveLocalChat({ ...localChat, title: newTitle });
}
} catch (e) {
console.error('Failed to rename chat:', e);
}
};
const handlePinChat = async (chatId: string) => {
const chat = chats.find((c) => c.id === chatId);
if (!chat) {
return;
}
const newPinned = !chat.pinned;
try {
// Update in database
await fetch(`/api/chats/${chatId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned: newPinned }),
});
// Update local state and re-sort (pinned first)
setChats((prev) => {
const updated = prev.map((c) => (c.id === chatId ? { ...c, pinned: newPinned } : c));
return updated.sort((a, b) => {
if (a.pinned && !b.pinned) {
return -1;
}
if (!a.pinned && b.pinned) {
return 1;
}
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
});
// Update localStorage
const localChat = getLocalChat(chatId);
if (localChat) {
saveLocalChat({ ...localChat, pinned: newPinned });
}
} catch (e) {
console.error('Failed to pin chat:', e);
}
};
const handleRemoveChat = async (chatId: string) => {
if (!window.confirm('Are you sure you want to delete this chat?')) {
return;
}
try {
// Delete from database
await fetch(`/api/chats/${chatId}`, {
method: 'DELETE',
});
// Remove from local state
setChats((prev) => prev.filter((c) => c.id !== chatId));
// Remove from localStorage
deleteLocalChat(chatId);
deleteScrollPosition(chatId);
// If this was the active chat, clear messages
if (activeChatId === chatId) {
setActiveChatId(null);
setMessages([
{
id: Date.now().toString(),
role: 'assistant',
content: 'Hello! I am an AI assistant. How can I help you today?',
},
]);
}
} catch (e) {
console.error('Failed to delete chat:', e);
}
};
return (
<>
<AppShell
@@ -427,10 +564,10 @@ export default function ChatLayout() {
<Stack gap="xs">
{chats.length > 0 ? (
chats.map((chat) => (
<UnstyledButton
<Group
key={chat.id}
onClick={() => handleSelectChat(chat)}
p="sm"
gap={0}
wrap="nowrap"
style={{
borderRadius: 'var(--mantine-radius-md)',
backgroundColor:
@@ -440,13 +577,59 @@ export default function ChatLayout() {
transition: 'background-color 0.2s',
}}
>
<Group wrap="nowrap">
<IconMessage size={18} color="gray" style={{ minWidth: 18 }} />
<Text size="sm" truncate>
{chat.title}
</Text>
</Group>
</UnstyledButton>
<UnstyledButton
onClick={() => handleSelectChat(chat)}
p="sm"
style={{ flex: 1, minWidth: 0 }}
>
<Group wrap="nowrap" gap="xs">
{chat.pinned ? (
<IconPin size={18} color="gray" style={{ minWidth: 18 }} />
) : (
<IconMessage size={18} color="gray" style={{ minWidth: 18 }} />
)}
<Text size="sm" truncate style={{ flex: 1 }}>
{chat.title}
</Text>
</Group>
</UnstyledButton>
<Menu position="bottom-end" withArrow>
<Menu.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
mr="xs"
onClick={(e) => e.stopPropagation()}
>
<IconDotsVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconPencil size={14} />}
onClick={() => handleRenameChat(chat.id)}
>
Rename
</Menu.Item>
<Menu.Item
leftSection={<IconPin size={14} />}
onClick={() => handlePinChat(chat.id)}
>
{chat.pinned ? 'Unpin' : 'Pin'}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconTrash size={14} />}
onClick={() => handleRemoveChat(chat.id)}
>
Remove
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
))
) : (
<Text size="sm" c="dimmed" ta="center" mt="xl">
+70
View File
@@ -14,6 +14,7 @@ interface Chat {
title: string;
updatedAt: string;
messages: Message[];
pinned?: boolean;
}
const CHATS_STORAGE_KEY = 'chat-gpz-chats';
@@ -133,3 +134,72 @@ export function clearLocalChats(): void {
}
localStorage.removeItem(CHATS_STORAGE_KEY);
}
// ============================================
// Scroll Position Storage
// ============================================
const SCROLL_POSITIONS_KEY = 'chat-gpz-scroll-positions';
interface ScrollPositions {
[chatId: string]: number;
}
/**
* Get all stored scroll positions
*/
function getScrollPositions(): ScrollPositions {
if (typeof window === 'undefined') {
return {};
}
try {
const stored = localStorage.getItem(SCROLL_POSITIONS_KEY);
if (!stored) {
return {};
}
return JSON.parse(stored) as ScrollPositions;
} catch {
return {};
}
}
/**
* Save scroll positions to localStorage
*/
function setScrollPositions(positions: ScrollPositions): void {
if (typeof window === 'undefined') {
return;
}
try {
localStorage.setItem(SCROLL_POSITIONS_KEY, JSON.stringify(positions));
} catch {
// Ignore storage errors
}
}
/**
* Get scroll position for a specific chat
* Returns null if no position saved (indicating should scroll to bottom for new chats)
*/
export function getScrollPosition(chatId: string): number | null {
const positions = getScrollPositions();
return positions[chatId] ?? null;
}
/**
* Save scroll position for a specific chat
*/
export function saveScrollPosition(chatId: string, position: number): void {
const positions = getScrollPositions();
positions[chatId] = position;
setScrollPositions(positions);
}
/**
* Delete scroll position for a chat (when chat is deleted)
*/
export function deleteScrollPosition(chatId: string): void {
const positions = getScrollPositions();
delete positions[chatId];
setScrollPositions(positions);
}
+1
View File
@@ -23,6 +23,7 @@ model Chat {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String
pinned Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id])
messages Message[]