From c6352f4a19029c75014c12ba981ced0f13e61391 Mon Sep 17 00:00:00 2001 From: Zacharias-Brohn Date: Wed, 14 Jan 2026 21:45:28 +0100 Subject: [PATCH] changes --- app/api/chats/[id]/route.ts | 93 +++++++++++++ app/api/chats/route.ts | 2 +- components/Chat/ChatLayout.tsx | 231 +++++++++++++++++++++++++++++---- lib/chatStorage.ts | 70 ++++++++++ prisma/schema.prisma | 1 + 5 files changed, 372 insertions(+), 25 deletions(-) create mode 100644 app/api/chats/[id]/route.ts diff --git a/app/api/chats/[id]/route.ts b/app/api/chats/[id]/route.ts new file mode 100644 index 0000000..9db6578 --- /dev/null +++ b/app/api/chats/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index 221621a..a29dde3 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -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' }, diff --git a/components/Chat/ChatLayout.tsx b/components/Chat/ChatLayout.tsx index 1554c45..04dcc84 100644 --- a/components/Chat/ChatLayout.tsx +++ b/components/Chat/ChatLayout.tsx @@ -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 ( <> {chats.length > 0 ? ( chats.map((chat) => ( - 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', }} > - - - - {chat.title} - - - + handleSelectChat(chat)} + p="sm" + style={{ flex: 1, minWidth: 0 }} + > + + {chat.pinned ? ( + + ) : ( + + )} + + {chat.title} + + + + + + + e.stopPropagation()} + > + + + + + } + onClick={() => handleRenameChat(chat.id)} + > + Rename + + } + onClick={() => handlePinChat(chat.id)} + > + {chat.pinned ? 'Unpin' : 'Pin'} + + + } + onClick={() => handleRemoveChat(chat.id)} + > + Remove + + + + )) ) : ( diff --git a/lib/chatStorage.ts b/lib/chatStorage.ts index cc894da..5c190f7 100644 --- a/lib/chatStorage.ts +++ b/lib/chatStorage.ts @@ -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); +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf5e1bd..e0425d2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[]