mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat: improve chat UX and add optional auth for public feedback
- Add debounced search (200ms) in chat sidebar for better performance - Add toast notifications for conversation actions (archive, restore, delete, pin) - Add race condition protection when loading conversations - Add OptionalAuthGuard for public feedback endpoint (unauthenticated access) - Add backHref prop to PageHeader component for back navigation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
0893ed7daa
commit
c85cd4556c
7 changed files with 192 additions and 53 deletions
|
|
@ -26,18 +26,36 @@
|
|||
const MIN_WIDTH = 260;
|
||||
const MAX_WIDTH = 450;
|
||||
|
||||
// Search state
|
||||
// Search state with debouncing
|
||||
let searchQuery = $state('');
|
||||
let debouncedSearchQuery = $state('');
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Debounce search input (200ms delay)
|
||||
$effect(() => {
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
}
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
debouncedSearchQuery = searchQuery;
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
if (searchDebounceTimer) {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Get conversations from store
|
||||
let conversations = $derived(conversationsStore.conversations);
|
||||
let isLoading = $derived(conversationsStore.isLoading);
|
||||
|
||||
// Filtered conversations based on search
|
||||
// Filtered conversations based on debounced search
|
||||
let filteredConversations = $derived(
|
||||
searchQuery.trim()
|
||||
debouncedSearchQuery.trim()
|
||||
? conversations.filter((conv) =>
|
||||
conv.title?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
conv.title?.toLowerCase().includes(debouncedSearchQuery.toLowerCase())
|
||||
)
|
||||
: conversations
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,17 @@ let archivedConversations = $state<Conversation[]>([]);
|
|||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/**
|
||||
* Sort conversations: pinned first, then by updatedAt descending
|
||||
*/
|
||||
function sortConversations(list: Conversation[]): Conversation[] {
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}
|
||||
|
||||
export const conversationsStore = {
|
||||
// Getters
|
||||
get conversations() {
|
||||
|
|
@ -102,6 +113,9 @@ export const conversationsStore = {
|
|||
conversations = conversations.filter((c) => c.id !== conversationId);
|
||||
archivedConversations = [{ ...conversation, isArchived: true }, ...archivedConversations];
|
||||
}
|
||||
toastStore.success('Konversation archiviert');
|
||||
} else {
|
||||
toastStore.error('Konversation konnte nicht archiviert werden');
|
||||
}
|
||||
|
||||
return success;
|
||||
|
|
@ -117,8 +131,11 @@ export const conversationsStore = {
|
|||
const conversation = archivedConversations.find((c) => c.id === conversationId);
|
||||
if (conversation) {
|
||||
archivedConversations = archivedConversations.filter((c) => c.id !== conversationId);
|
||||
conversations = [{ ...conversation, isArchived: false }, ...conversations];
|
||||
conversations = sortConversations([{ ...conversation, isArchived: false }, ...conversations]);
|
||||
}
|
||||
toastStore.success('Konversation wiederhergestellt');
|
||||
} else {
|
||||
toastStore.error('Konversation konnte nicht wiederhergestellt werden');
|
||||
}
|
||||
|
||||
return success;
|
||||
|
|
@ -133,6 +150,9 @@ export const conversationsStore = {
|
|||
if (success) {
|
||||
conversations = conversations.filter((c) => c.id !== conversationId);
|
||||
archivedConversations = archivedConversations.filter((c) => c.id !== conversationId);
|
||||
toastStore.success('Konversation gelöscht');
|
||||
} else {
|
||||
toastStore.error('Konversation konnte nicht gelöscht werden');
|
||||
}
|
||||
|
||||
return success;
|
||||
|
|
@ -145,15 +165,11 @@ export const conversationsStore = {
|
|||
const success = await conversationService.pinConversation(conversationId);
|
||||
|
||||
if (success) {
|
||||
conversations = conversations.map((c) =>
|
||||
c.id === conversationId ? { ...c, isPinned: true } : c
|
||||
conversations = sortConversations(
|
||||
conversations.map((c) => (c.id === conversationId ? { ...c, isPinned: true } : c))
|
||||
);
|
||||
// Re-sort: pinned first, then by updatedAt
|
||||
conversations = [...conversations].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
} else {
|
||||
toastStore.error('Konversation konnte nicht angepinnt werden');
|
||||
}
|
||||
|
||||
return success;
|
||||
|
|
@ -166,15 +182,11 @@ export const conversationsStore = {
|
|||
const success = await conversationService.unpinConversation(conversationId);
|
||||
|
||||
if (success) {
|
||||
conversations = conversations.map((c) =>
|
||||
c.id === conversationId ? { ...c, isPinned: false } : c
|
||||
conversations = sortConversations(
|
||||
conversations.map((c) => (c.id === conversationId ? { ...c, isPinned: false } : c))
|
||||
);
|
||||
// Re-sort: pinned first, then by updatedAt
|
||||
conversations = [...conversations].sort((a, b) => {
|
||||
if (a.isPinned && !b.isPinned) return -1;
|
||||
if (!a.isPinned && b.isPinned) return 1;
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
} else {
|
||||
toastStore.error('Konversation konnte nicht losgelöst werden');
|
||||
}
|
||||
|
||||
return success;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { conversationService } from '$lib/services/conversation';
|
||||
import { chatService } from '$lib/services/chat';
|
||||
import { documentService } from '$lib/services/document';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import MessageList from '$lib/components/chat/MessageList.svelte';
|
||||
import ChatInput from '$lib/components/chat/ChatInput.svelte';
|
||||
import ChatLayout from '$lib/components/chat/ChatLayout.svelte';
|
||||
|
|
@ -33,44 +33,77 @@
|
|||
let showVersionsModal = $state(false);
|
||||
let showDocumentPanel = $state(true);
|
||||
|
||||
// Track current request to prevent race conditions
|
||||
let currentLoadId = $state(0);
|
||||
|
||||
const conversationId = $derived($page.params.id ?? '');
|
||||
const isDocumentMode = $derived(conversation?.documentMode ?? false);
|
||||
|
||||
onMount(async () => {
|
||||
await loadData();
|
||||
// React to conversationId changes with race condition protection
|
||||
$effect(() => {
|
||||
if (conversationId) {
|
||||
loadData(conversationId);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
async function loadData(targetConversationId: string) {
|
||||
// Increment load ID to track this request
|
||||
const loadId = ++currentLoadId;
|
||||
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
// Load models
|
||||
models = await chatService.getModels();
|
||||
const loadedModels = await chatService.getModels();
|
||||
|
||||
// Check if this request is still current
|
||||
if (loadId !== currentLoadId) return;
|
||||
models = loadedModels;
|
||||
|
||||
// Load conversation
|
||||
conversation = await conversationService.getConversation(conversationId);
|
||||
const loadedConversation = await conversationService.getConversation(targetConversationId);
|
||||
|
||||
if (!conversation) {
|
||||
// Check if this request is still current
|
||||
if (loadId !== currentLoadId) return;
|
||||
|
||||
if (!loadedConversation) {
|
||||
error = 'Konversation nicht gefunden';
|
||||
return;
|
||||
}
|
||||
|
||||
// Set model from conversation
|
||||
selectedModelId = conversation.modelId;
|
||||
conversation = loadedConversation;
|
||||
selectedModelId = loadedConversation.modelId;
|
||||
|
||||
// Load messages
|
||||
messages = await conversationService.getMessages(conversationId);
|
||||
const loadedMessages = await conversationService.getMessages(targetConversationId);
|
||||
|
||||
// Check if this request is still current
|
||||
if (loadId !== currentLoadId) return;
|
||||
messages = loadedMessages;
|
||||
|
||||
// Load document if in document mode
|
||||
if (conversation.documentMode) {
|
||||
document = await documentService.getLatestDocument(conversationId);
|
||||
documentContent = document?.content ?? '';
|
||||
if (loadedConversation.documentMode) {
|
||||
const loadedDocument = await documentService.getLatestDocument(targetConversationId);
|
||||
|
||||
// Check if this request is still current
|
||||
if (loadId !== currentLoadId) return;
|
||||
document = loadedDocument;
|
||||
documentContent = loadedDocument?.content ?? '';
|
||||
} else {
|
||||
document = null;
|
||||
documentContent = '';
|
||||
}
|
||||
} catch (e) {
|
||||
// Only show error if this request is still current
|
||||
if (loadId !== currentLoadId) return;
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden';
|
||||
toastStore.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
// Only update loading state if this request is still current
|
||||
if (loadId === currentLoadId) {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@
|
|||
* </PageHeader>
|
||||
* ```
|
||||
*
|
||||
* @example With back navigation
|
||||
* ```svelte
|
||||
* <PageHeader title="Space Details" backHref="/spaces" />
|
||||
* ```
|
||||
*
|
||||
* @example With breadcrumb and icon
|
||||
* ```svelte
|
||||
* <PageHeader title="Edit Profile">
|
||||
|
|
@ -48,6 +53,8 @@
|
|||
size?: HeaderSize;
|
||||
/** Whether to show bottom border */
|
||||
bordered?: boolean;
|
||||
/** Back navigation href (shows back arrow button) */
|
||||
backHref?: string;
|
||||
/** Icon snippet (before title) */
|
||||
icon?: Snippet;
|
||||
/** Breadcrumb snippet (above title) */
|
||||
|
|
@ -65,6 +72,7 @@
|
|||
description,
|
||||
size = 'md',
|
||||
bordered = false,
|
||||
backHref,
|
||||
icon,
|
||||
breadcrumb,
|
||||
actions,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
|
||||
/**
|
||||
* Optional authentication guard
|
||||
* Attaches user to request if valid token is present, but doesn't require it
|
||||
*/
|
||||
@Injectable()
|
||||
export class OptionalAuthGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
// No token - allow request but no user
|
||||
request.user = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const publicKey = this.configService.get<string>('jwt.publicKey');
|
||||
if (!publicKey) {
|
||||
request.user = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
const audience = this.configService.get<string>('jwt.audience');
|
||||
const issuer = this.configService.get<string>('jwt.issuer');
|
||||
|
||||
const payload = jwt.verify(token, publicKey, {
|
||||
algorithms: ['RS256'],
|
||||
audience,
|
||||
issuer,
|
||||
}) as jwt.JwtPayload;
|
||||
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
};
|
||||
} catch {
|
||||
// Invalid token - allow request but no user
|
||||
request.user = null;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,15 +11,16 @@ import {
|
|||
} from '@nestjs/common';
|
||||
import { FeedbackService } from './feedback.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { OptionalAuthGuard } from '../common/guards/optional-auth.guard';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
import { CreateFeedbackDto, FeedbackQueryDto } from './dto';
|
||||
|
||||
@Controller('feedback')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FeedbackController {
|
||||
constructor(private readonly feedbackService: FeedbackService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async createFeedback(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Body() dto: CreateFeedbackDto,
|
||||
|
|
@ -30,11 +31,16 @@ export class FeedbackController {
|
|||
}
|
||||
|
||||
@Get('public')
|
||||
async getPublicFeedback(@CurrentUser() user: CurrentUserData, @Query() query: FeedbackQueryDto) {
|
||||
return this.feedbackService.getPublicFeedback(user.userId, query);
|
||||
@UseGuards(OptionalAuthGuard)
|
||||
async getPublicFeedback(
|
||||
@CurrentUser() user: CurrentUserData | null,
|
||||
@Query() query: FeedbackQueryDto
|
||||
) {
|
||||
return this.feedbackService.getPublicFeedback(user?.userId || null, query);
|
||||
}
|
||||
|
||||
@Get('my')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getMyFeedback(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('appId') appId?: string
|
||||
|
|
@ -43,11 +49,13 @@ export class FeedbackController {
|
|||
}
|
||||
|
||||
@Post(':id/vote')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async vote(@CurrentUser() user: CurrentUserData, @Param('id') feedbackId: string) {
|
||||
return this.feedbackService.vote(user.userId, feedbackId);
|
||||
}
|
||||
|
||||
@Delete(':id/vote')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async unvote(@CurrentUser() user: CurrentUserData, @Param('id') feedbackId: string) {
|
||||
return this.feedbackService.unvote(user.userId, feedbackId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export class FeedbackService {
|
|||
};
|
||||
}
|
||||
|
||||
async getPublicFeedback(userId: string, query: FeedbackQueryDto) {
|
||||
async getPublicFeedback(userId: string | null, query: FeedbackQueryDto) {
|
||||
const db = this.getDb();
|
||||
const { appId, status, category, sort = 'votes', limit = 20, offset = 0 } = query;
|
||||
|
||||
|
|
@ -90,22 +90,25 @@ export class FeedbackService {
|
|||
.from(userFeedback)
|
||||
.where(and(...conditions));
|
||||
|
||||
// Get user's votes
|
||||
const feedbackIds = feedbackItems.map((f) => f.id);
|
||||
const userVotes =
|
||||
feedbackIds.length > 0
|
||||
? await db
|
||||
.select({ feedbackId: feedbackVotes.feedbackId })
|
||||
.from(feedbackVotes)
|
||||
.where(
|
||||
and(
|
||||
eq(feedbackVotes.userId, userId),
|
||||
sql`${feedbackVotes.feedbackId} = ANY(${feedbackIds})`
|
||||
// Get user's votes (only if authenticated)
|
||||
let votedFeedbackIds = new Set<string>();
|
||||
if (userId) {
|
||||
const feedbackIds = feedbackItems.map((f) => f.id);
|
||||
const userVotes =
|
||||
feedbackIds.length > 0
|
||||
? await db
|
||||
.select({ feedbackId: feedbackVotes.feedbackId })
|
||||
.from(feedbackVotes)
|
||||
.where(
|
||||
and(
|
||||
eq(feedbackVotes.userId, userId),
|
||||
sql`${feedbackVotes.feedbackId} = ANY(${feedbackIds})`
|
||||
)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
: [];
|
||||
|
||||
const votedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId));
|
||||
votedFeedbackIds = new Set(userVotes.map((v) => v.feedbackId));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue