feat(chat): add conversation pinning and date-based sections

- Add pin/unpin functionality with UI button and visual indicator
- Group conversations by date sections (Today, Yesterday, This Week, This Month, Older)
- Pinned conversations appear in separate "Angepinnt" section at top
- Refactor services and store to use shared types from @chat/types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-29 23:01:18 +01:00
parent 819e4c9a2f
commit 4f06301fe3
9 changed files with 298 additions and 230 deletions

View file

@ -42,6 +42,76 @@
: conversations
);
// Split into pinned and unpinned
let pinnedConversations = $derived(filteredConversations.filter((conv) => conv.isPinned));
let unpinnedConversations = $derived(filteredConversations.filter((conv) => !conv.isPinned));
// Date section types
type DateSection = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth' | 'older';
const sectionLabels: Record<DateSection, string> = {
today: 'Heute',
yesterday: 'Gestern',
thisWeek: 'Diese Woche',
thisMonth: 'Dieser Monat',
older: 'Älter'
};
function getDateSection(dateString: string): DateSection {
const date = new Date(dateString);
const now = new Date();
// Reset time to compare just dates
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const dateOnly = new Date(date.getFullYear(), date.getMonth(), date.getDate());
if (dateOnly.getTime() === today.getTime()) {
return 'today';
}
if (dateOnly.getTime() === yesterday.getTime()) {
return 'yesterday';
}
// This week (last 7 days)
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
if (dateOnly > weekAgo) {
return 'thisWeek';
}
// This month
const monthAgo = new Date(today);
monthAgo.setDate(monthAgo.getDate() - 30);
if (dateOnly > monthAgo) {
return 'thisMonth';
}
return 'older';
}
// Group unpinned conversations by date sections
let groupedConversations = $derived(() => {
const groups: Record<DateSection, typeof unpinnedConversations> = {
today: [],
yesterday: [],
thisWeek: [],
thisMonth: [],
older: []
};
for (const conv of unpinnedConversations) {
const section = getDateSection(conv.updatedAt || conv.createdAt);
groups[section].push(conv);
}
return groups;
});
const sectionOrder: DateSection[] = ['today', 'yesterday', 'thisWeek', 'thisMonth', 'older'];
// Resizer handlers
function startResize(e: MouseEvent) {
e.preventDefault();
@ -66,7 +136,7 @@
window.addEventListener('mouseup', stopResize);
if (authStore.user) {
conversationsStore.loadConversations(authStore.user.id);
conversationsStore.loadConversations();
}
});
@ -240,81 +310,160 @@
{/if}
</div>
{:else}
{#each filteredConversations as conv (conv.id)}
<a
href="/chat/{conv.id}"
class="group block w-full rounded-xl bg-white/60 dark:bg-white/5 backdrop-blur-sm border border-black/10 dark:border-white/20 p-4 text-left transition-all mb-3 hover:shadow-md hover:bg-white/80 dark:hover:bg-white/10
{isActive(conv.id)
? 'bg-white/90 dark:bg-white/15 shadow-md border-primary/30'
: ''}"
>
<!-- Title Row -->
<div class="mb-1.5 flex items-center gap-2">
{#if conv.isPinned}
<PushPin
size={16}
weight="fill"
class="flex-shrink-0 text-primary"
/>
{:else}
<ChatCircle
size={16}
weight={isActive(conv.id) ? 'fill' : 'regular'}
class="flex-shrink-0 {isActive(conv.id)
? 'text-primary'
: 'text-muted-foreground'}"
/>
{/if}
<h3 class="text-sm font-semibold line-clamp-1 text-foreground flex-1">
{conv.title || 'Neue Konversation'}
</h3>
</div>
<!-- Preview -->
<p class="mb-2 text-sm text-muted-foreground line-clamp-2">
{getPreview(conv.title)}
</p>
<!-- Footer -->
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">
{formatDate(conv.updatedAt || conv.createdAt)}
</span>
<div class="flex items-center gap-1">
{#if conv.documentMode}
<span
class="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full"
>
Dokument
</span>
{/if}
<!-- Action Buttons (visible on hover) -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onclick={(e) => handleTogglePin(e, conv.id, conv.isPinned)}
class="p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors {conv.isPinned ? 'text-primary' : ''}"
title={conv.isPinned ? 'Nicht mehr anpinnen' : 'Anpinnen'}
>
<PushPin size={14} weight={conv.isPinned ? 'fill' : 'bold'} />
</button>
<button
onclick={(e) => handleArchive(e, conv.id)}
class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
title="Archivieren"
>
<Archive size={14} weight="bold" />
</button>
<button
onclick={(e) => handleDelete(e, conv.id)}
class="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
title="Löschen"
>
<Trash size={14} weight="bold" />
</button>
<!-- Pinned Section -->
{#if pinnedConversations.length > 0}
<div class="mb-5">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 flex items-center gap-1.5">
<PushPin size={12} weight="fill" class="text-primary" />
Angepinnt
</h4>
{#each pinnedConversations as conv (conv.id)}
<a
href="/chat/{conv.id}"
class="group block w-full rounded-xl bg-white/60 dark:bg-white/5 backdrop-blur-sm border border-black/10 dark:border-white/20 p-4 text-left transition-all mb-3 hover:shadow-md hover:bg-white/80 dark:hover:bg-white/10
{isActive(conv.id)
? 'bg-white/90 dark:bg-white/15 shadow-md border-primary/30'
: ''}"
>
<!-- Title Row -->
<div class="mb-1.5 flex items-center gap-2">
<PushPin
size={16}
weight="fill"
class="flex-shrink-0 text-primary"
/>
<h3 class="text-sm font-semibold line-clamp-1 text-foreground flex-1">
{conv.title || 'Neue Konversation'}
</h3>
</div>
</div>
<!-- Preview -->
<p class="mb-2 text-sm text-muted-foreground line-clamp-2">
{getPreview(conv.title)}
</p>
<!-- Footer -->
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">
{formatDate(conv.updatedAt || conv.createdAt)}
</span>
<div class="flex items-center gap-1">
{#if conv.documentMode}
<span
class="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full"
>
Dokument
</span>
{/if}
<!-- Action Buttons (visible on hover) -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onclick={(e) => handleTogglePin(e, conv.id, true)}
class="p-1.5 text-primary hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
title="Nicht mehr anpinnen"
>
<PushPin size={14} weight="fill" />
</button>
<button
onclick={(e) => handleArchive(e, conv.id)}
class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
title="Archivieren"
>
<Archive size={14} weight="bold" />
</button>
<button
onclick={(e) => handleDelete(e, conv.id)}
class="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
title="Löschen"
>
<Trash size={14} weight="bold" />
</button>
</div>
</div>
</div>
</a>
{/each}
</div>
{/if}
<!-- Grouped Conversations by Date -->
{#each sectionOrder as section}
{@const convs = groupedConversations()[section]}
{#if convs.length > 0}
<div class="mb-5">
<h4 class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
{sectionLabels[section]}
</h4>
{#each convs as conv (conv.id)}
<a
href="/chat/{conv.id}"
class="group block w-full rounded-xl bg-white/60 dark:bg-white/5 backdrop-blur-sm border border-black/10 dark:border-white/20 p-4 text-left transition-all mb-3 hover:shadow-md hover:bg-white/80 dark:hover:bg-white/10
{isActive(conv.id)
? 'bg-white/90 dark:bg-white/15 shadow-md border-primary/30'
: ''}"
>
<!-- Title Row -->
<div class="mb-1.5 flex items-center gap-2">
<ChatCircle
size={16}
weight={isActive(conv.id) ? 'fill' : 'regular'}
class="flex-shrink-0 {isActive(conv.id)
? 'text-primary'
: 'text-muted-foreground'}"
/>
<h3 class="text-sm font-semibold line-clamp-1 text-foreground flex-1">
{conv.title || 'Neue Konversation'}
</h3>
</div>
<!-- Preview -->
<p class="mb-2 text-sm text-muted-foreground line-clamp-2">
{getPreview(conv.title)}
</p>
<!-- Footer -->
<div class="flex items-center justify-between">
<span class="text-xs text-muted-foreground">
{formatDate(conv.updatedAt || conv.createdAt)}
</span>
<div class="flex items-center gap-1">
{#if conv.documentMode}
<span
class="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full"
>
Dokument
</span>
{/if}
<!-- Action Buttons (visible on hover) -->
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onclick={(e) => handleTogglePin(e, conv.id, false)}
class="p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
title="Anpinnen"
>
<PushPin size={14} weight="bold" />
</button>
<button
onclick={(e) => handleArchive(e, conv.id)}
class="p-1.5 text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
title="Archivieren"
>
<Archive size={14} weight="bold" />
</button>
<button
onclick={(e) => handleDelete(e, conv.id)}
class="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-lg transition-colors"
title="Löschen"
>
<Trash size={14} weight="bold" />
</button>
</div>
</div>
</div>
</a>
{/each}
</div>
</a>
{/if}
{/each}
{/if}
</div>

View file

@ -7,6 +7,30 @@
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import type {
Conversation,
Message,
Template,
Space,
SpaceMember,
Document,
AIModel,
ChatMessage,
ChatCompletionResponse,
} from '@chat/types';
// Re-export types for convenience
export type {
Conversation,
Message,
Template,
Space,
SpaceMember,
Document,
AIModel,
ChatMessage,
ChatCompletionResponse,
};
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
@ -63,30 +87,6 @@ async function fetchApi<T>(
// ============ Conversation API ============
export type Conversation = {
id: string;
userId: string;
modelId: string;
templateId?: string;
spaceId?: string;
title?: string;
conversationMode: 'free' | 'guided' | 'template';
documentMode: boolean;
isArchived: boolean;
isPinned: boolean;
createdAt: string;
updatedAt: string;
};
export type Message = {
id: string;
conversationId: string;
sender: 'user' | 'assistant' | 'system';
messageText: string;
createdAt: string;
updatedAt: string;
};
export const conversationApi = {
async getConversations(spaceId?: string): Promise<Conversation[]> {
const query = spaceId ? `?spaceId=${spaceId}` : '';
@ -230,21 +230,6 @@ export const conversationApi = {
// ============ Template API ============
export type Template = {
id: string;
userId: string;
name: string;
description: string | null;
systemPrompt: string;
initialQuestion: string | null;
modelId: string | null;
color: string;
isDefault: boolean;
documentMode: boolean;
createdAt: string;
updatedAt: string;
};
export const templateApi = {
async getTemplates(): Promise<Template[]> {
const { data, error } = await fetchApi<Template[]>('/templates');
@ -341,29 +326,6 @@ export const templateApi = {
// ============ Space API ============
export type Space = {
id: string;
ownerId: string;
name: string;
description?: string;
isArchived: boolean;
createdAt: string;
updatedAt: string;
};
export type SpaceMember = {
id: string;
spaceId: string;
userId: string;
role: 'owner' | 'admin' | 'member' | 'viewer';
invitationStatus: 'pending' | 'accepted' | 'declined';
invitedBy?: string;
invitedAt: string;
joinedAt?: string;
createdAt: string;
updatedAt: string;
};
export const spaceApi = {
async getUserSpaces(): Promise<Space[]> {
const { data, error } = await fetchApi<Space[]>('/spaces');
@ -520,15 +482,6 @@ export const spaceApi = {
// ============ Document API ============
export type Document = {
id: string;
conversationId: string;
version: number;
content: string;
createdAt: string;
updatedAt: string;
};
export const documentApi = {
async getLatestDocument(conversationId: string): Promise<Document | null> {
const { data, error } = await fetchApi<Document | null>(
@ -604,26 +557,9 @@ export const documentApi = {
// ============ Model API ============
export type Model = {
id: string;
name: string;
description?: string;
provider: 'gemini' | 'azure' | 'openai';
parameters?: {
deployment?: string;
temperature?: number;
max_tokens?: number;
top_p?: number;
};
isActive: boolean;
isDefault: boolean;
createdAt: string;
updatedAt: string;
};
export const modelApi = {
async getModels(): Promise<Model[]> {
const { data, error } = await fetchApi<Model[]>('/models');
async getModels(): Promise<AIModel[]> {
const { data, error } = await fetchApi<AIModel[]>('/models');
if (error) {
console.error('Error loading models:', error);
return [];
@ -631,8 +567,8 @@ export const modelApi = {
return data || [];
},
async getModel(id: string): Promise<Model | null> {
const { data, error } = await fetchApi<Model>(`/models/${id}`);
async getModel(id: string): Promise<AIModel | null> {
const { data, error } = await fetchApi<AIModel>(`/models/${id}`);
if (error) {
console.error('Error loading model:', error);
return null;
@ -643,20 +579,6 @@ export const modelApi = {
// ============ Chat API ============
export type ChatMessage = {
role: 'system' | 'user' | 'assistant';
content: string;
};
export type ChatCompletionResponse = {
content: string;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
};
export const chatApi = {
async createCompletion(options: {
messages: ChatMessage[];

View file

@ -7,10 +7,10 @@ import {
modelApi,
type ChatMessage,
type ChatCompletionResponse,
type Model,
type AIModel,
} from './api';
export type { ChatMessage, ChatCompletionResponse };
export type { ChatMessage, ChatCompletionResponse, AIModel };
export interface ChatCompletionRequest {
messages: ChatMessage[];
@ -23,7 +23,7 @@ export const chatService = {
/**
* Get available AI models
*/
async getModels(): Promise<Model[]> {
async getModels(): Promise<AIModel[]> {
return modelApi.getModels();
},

View file

@ -1,5 +1,8 @@
/**
* Conversation Service - CRUD operations via Backend API
*
* Note: userId is derived from JWT token on the backend,
* so we don't need to pass it from the frontend.
*/
import { conversationApi, chatApi, type Conversation, type Message, type ChatMessage } from './api';
@ -10,36 +13,35 @@ export const conversationService = {
/**
* Create a new conversation
*/
async createConversation(
userId: string,
modelId: string,
mode: 'free' | 'guided' | 'template' = 'free',
templateId?: string,
documentMode: boolean = false,
spaceId?: string
): Promise<string | null> {
async createConversation(options: {
modelId: string;
mode?: 'free' | 'guided' | 'template';
templateId?: string;
documentMode?: boolean;
spaceId?: string;
}): Promise<string | null> {
const conversation = await conversationApi.createConversation({
modelId,
conversationMode: mode,
templateId,
documentMode,
spaceId,
modelId: options.modelId,
conversationMode: options.mode ?? 'free',
templateId: options.templateId,
documentMode: options.documentMode ?? false,
spaceId: options.spaceId,
});
return conversation?.id || null;
},
/**
* Get all active conversations for a user
* Get all active conversations
*/
async getConversations(userId: string, spaceId?: string): Promise<Conversation[]> {
async getConversations(spaceId?: string): Promise<Conversation[]> {
return conversationApi.getConversations(spaceId);
},
/**
* Get archived conversations
*/
async getArchivedConversations(userId: string): Promise<Conversation[]> {
async getArchivedConversations(): Promise<Conversation[]> {
return conversationApi.getArchivedConversations();
},

View file

@ -27,14 +27,14 @@ export const conversationsStore = {
},
/**
* Load conversations for a user
* Load conversations (userId is derived from JWT on backend)
*/
async loadConversations(userId: string, spaceId?: string) {
async loadConversations(spaceId?: string) {
isLoading = true;
error = null;
try {
conversations = await conversationService.getConversations(userId, spaceId);
conversations = await conversationService.getConversations(spaceId);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load conversations';
conversations = [];
@ -46,12 +46,12 @@ export const conversationsStore = {
/**
* Load archived conversations
*/
async loadArchivedConversations(userId: string) {
async loadArchivedConversations() {
isLoading = true;
error = null;
try {
archivedConversations = await conversationService.getArchivedConversations(userId);
archivedConversations = await conversationService.getArchivedConversations();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load archived conversations';
archivedConversations = [];

View file

@ -10,7 +10,7 @@
onMount(async () => {
if (authStore.user) {
await conversationsStore.loadArchivedConversations(authStore.user.id);
await conversationsStore.loadArchivedConversations();
conversations = conversationsStore.archivedConversations;
}
isLoading = false;

View file

@ -72,13 +72,12 @@
const docMode = selectedTemplate?.documentMode || documentMode;
// Create new conversation
const conversationId = await conversationService.createConversation(
authStore.user.id,
modelToUse,
mode as 'free' | 'guided' | 'template',
selectedTemplate?.id,
docMode
);
const conversationId = await conversationService.createConversation({
modelId: modelToUse,
mode: mode as 'free' | 'guided' | 'template',
templateId: selectedTemplate?.id,
documentMode: docMode,
});
if (!conversationId) {
throw new Error('Konversation konnte nicht erstellt werden');
@ -92,7 +91,7 @@
);
// Reload conversations list
await conversationsStore.loadConversations(authStore.user.id);
await conversationsStore.loadConversations();
// Navigate to the new conversation
goto(`/chat/${conversationId}`);

View file

@ -36,7 +36,7 @@
// Load conversations in this space
if (authStore.user) {
conversations = await conversationService.getConversations(authStore.user.id, spaceId);
conversations = await conversationService.getConversations(spaceId);
}
// Load models
@ -56,14 +56,11 @@
async function handleNewChat() {
if (!authStore.user || !selectedModelId) return;
const conversationId = await conversationService.createConversation(
authStore.user.id,
selectedModelId,
'free',
undefined,
false,
spaceId
);
const conversationId = await conversationService.createConversation({
modelId: selectedModelId,
mode: 'free',
spaceId,
});
if (conversationId) {
goto(`/chat/${conversationId}`);

View file

@ -48,16 +48,15 @@
if (!template || !authStore.user) return;
// Create a new conversation with this template
const conversationId = await conversationService.createConversation(
authStore.user.id,
template.modelId || '550e8400-e29b-41d4-a716-446655440101', // Default to Gemini 2.5 Flash
'template',
template.id,
template.documentMode
);
const conversationId = await conversationService.createConversation({
modelId: template.modelId || '550e8400-e29b-41d4-a716-446655440101', // Default to Gemini 2.5 Flash
mode: 'template',
templateId: template.id,
documentMode: template.documentMode,
});
if (conversationId) {
await conversationsStore.loadConversations(authStore.user.id);
await conversationsStore.loadConversations();
goto(`/chat/${conversationId}`);
}
}