feature(chat): implement sidebar, chat listing, and improved chat handling
- Added `Sidebar` component with responsive layout for chat navigation. - Implemented `ChatStore` for managing chat data and fetching user chats. - Integrated JSON:API `ChatCollection` for consistent backend responses. - Added `GetUserChatsAction` to retrieve paginated user chats. - Refactored frontend to separate `ChatStore` and `MessageStore` for improved state management. - Updated styles, components, and tests for seamless chat interactions.
This commit is contained in:
parent
20a56d4adc
commit
ad0f0bf67b
18
backend/app/Actions/Chats/GetUserChatsAction.php
Normal file
18
backend/app/Actions/Chats/GetUserChatsAction.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Chats;
|
||||
|
||||
use App\Data\Chats\ChatResponseDto;
|
||||
use App\Models\User;
|
||||
use Illuminate\Pagination\AbstractPaginator;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
final readonly class GetUserChatsAction
|
||||
{
|
||||
public function chats(User $user): LengthAwarePaginator|AbstractPaginator
|
||||
{
|
||||
$chats = $user->chats()->latest()->paginate();
|
||||
|
||||
return $chats->through(fn ($chat) => ChatResponseDto::fromModel($chat));
|
||||
}
|
||||
}
|
||||
@ -3,14 +3,20 @@
|
||||
namespace App\Http\Controllers\Chats;
|
||||
|
||||
use App\Actions\Chats\CreateChatAction;
|
||||
use App\Actions\Chats\GetUserChatsAction;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Chats\CreateChatRequest;
|
||||
use App\Http\Resources\Chats\ChatResponseResource;
|
||||
use App\Models\Chat;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Attributes\Controllers\Middleware;
|
||||
|
||||
#[Middleware('auth:sanctum')]
|
||||
class ChatController extends Controller
|
||||
{
|
||||
public function index(Chat $chat) {}
|
||||
public function index(Request $request, GetUserChatsAction $getUserChatsAction)
|
||||
{
|
||||
return ChatResponseResource::collection($getUserChatsAction->chats($request->user()));
|
||||
}
|
||||
|
||||
public function store(CreateChatRequest $request, CreateChatAction $createChatAction)
|
||||
{
|
||||
|
||||
23
backend/app/Http/Resources/Chats/ChatCollection.php
Normal file
23
backend/app/Http/Resources/Chats/ChatCollection.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Chats;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Attributes\Collects;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
#[Collects(ChatResponseResource::class)]
|
||||
class ChatCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* Transform the resource collection into an array.
|
||||
*
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->collection,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,8 @@
|
||||
<app-header />
|
||||
<router-outlet />
|
||||
<div class="flex h-screen w-full">
|
||||
<div class="flex-1 flex flex-col min-w-0 transition-all duration-300">
|
||||
<main class="flex-1 flex items-start justify-center">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
</div>
|
||||
<app-sidebar></app-sidebar>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { Header } from './core/layout/header/header';
|
||||
import { Sidebar } from './core/layout/sidebar/sidebar';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, Header],
|
||||
imports: [RouterOutlet, Header, Sidebar],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.css'
|
||||
styleUrl: './app.css',
|
||||
})
|
||||
export class App {
|
||||
protected readonly title = signal('frontend');
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { API_URL } from '../core/tokens/api-urls';
|
||||
import { MessageCollection, MessageResponse, NewChatResponse } from './chat.types';
|
||||
import { ChatCollection, MessageCollection, MessageResponse, ChatResponse } from './chat.types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@ -11,7 +11,7 @@ export class ChatService {
|
||||
private apiUrl = inject(API_URL);
|
||||
|
||||
public newChat() {
|
||||
return this.http.post<NewChatResponse>(`${this.apiUrl}/chats`, {});
|
||||
return this.http.post<ChatResponse>(`${this.apiUrl}/chats`, {});
|
||||
}
|
||||
|
||||
public sendMessage(id: string, message: string) {
|
||||
@ -23,4 +23,8 @@ export class ChatService {
|
||||
public getMessages(id: string) {
|
||||
return this.http.get<MessageCollection>(`${this.apiUrl}/chats/${id}/messages`);
|
||||
}
|
||||
|
||||
public getChats() {
|
||||
return this.http.get<ChatCollection>(`${this.apiUrl}/chats`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,21 +1,13 @@
|
||||
<div class="max-w-[900px] mt-15 h-[80vh] mx-auto my-auto flex flex-col bg-white/5 backdrop-blur-[20px] border border-white/10 rounded-3xl shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.1)] overflow-hidden animate-[slideUpFade_0.8s_cubic-bezier(0.16,1,0.3,1)]">
|
||||
<div class="px-8 py-6 border-b border-white/10 flex items-center gap-4 bg-black/20">
|
||||
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-[#00f2fe] to-[#4facfe] flex items-center justify-center font-semibold text-xl shadow-[0_0_20px_rgba(79,172,254,0.4)] animate-[pulse_2s_infinite]">AI</div>
|
||||
<div>
|
||||
<h1 class="m-0 text-xl font-semibold tracking-wide bg-gradient-to-r from-white to-indigo-300 bg-clip-text text-transparent">Post Assistant</h1>
|
||||
<p class="m-0 mt-1 text-sm text-slate-400">Always online, ready to write.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-screen flex flex-col bg-white/5 backdrop-blur-[20px] border border-white/10 relative">
|
||||
<div class="flex-1 overflow-y-auto p-8 flex flex-col gap-6 scroll-smooth custom-scrollbar" #scrollContainer>
|
||||
@for (msg of chatStore.messages(); track msg.id) {
|
||||
@for (msg of messageStore.messages(); track msg.id) {
|
||||
<div
|
||||
class="flex flex-col max-w-[80%] animate-[messageAppear_0.5s_cubic-bezier(0.16,1,0.3,1)]"
|
||||
[ngClass]="msg.attributes.role === 'user' ? 'self-end items-end' : 'self-start items-start'"
|
||||
>
|
||||
<div
|
||||
class="px-5 py-4 rounded-2xl text-base leading-relaxed shadow-[0_4px_15px_rgba(0,0,0,0.1)] relative whitespace-pre-wrap"
|
||||
[ngClass]="msg.attributes.role === 'user' ? 'bg-gradient-to-br from-indigo-500 to-purple-600 text-white rounded-br-sm' : 'bg-white/10 border border-white/5 text-slate-200 rounded-bl-sm backdrop-blur-md'"
|
||||
class="px-5 py-4 rounded-2xl text-sm leading-relaxed shadow-[0_4px_15px_rgba(0,0,0,0.1)] relative whitespace-pre-wrap"
|
||||
[ngClass]="msg.attributes.role === 'user' ? 'bg-linear-to-br from-indigo-500 to-purple-600 text-white rounded-br-sm' : 'bg-white/10 border border-white/5 text-slate-200 rounded-bl-sm backdrop-blur-md'"
|
||||
>
|
||||
{{ msg.attributes.content }}
|
||||
</div>
|
||||
@ -25,7 +17,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (chatStore.isLoading()) {
|
||||
@if (messageStore.isLoading()) {
|
||||
<div class="flex items-center gap-1.5 px-5 py-4 bg-white/5 rounded-2xl rounded-bl-sm self-start animate-[fadeIn_0.3s_ease-in-out]">
|
||||
<div class="w-2 h-2 bg-[#4facfe] rounded-full animate-[typing_1.4s_infinite_ease-in-out_both] delay-[-0.32s]"></div>
|
||||
<div class="w-2 h-2 bg-[#4facfe] rounded-full animate-[typing_1.4s_infinite_ease-in-out_both] delay-[-0.16s]"></div>
|
||||
@ -34,27 +26,28 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="px-8 py-6 bg-black/30 border-t border-white/5">
|
||||
<div class="px-8 py-3">
|
||||
@if (errorMessage) {
|
||||
<div class="text-[#ff6b6b] text-sm mb-3 px-4 py-2 bg-[#ff6b6b]/10 rounded-lg border border-[#ff6b6b]/20 animate-[fadeIn_0.3s_ease-in-out]">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
}
|
||||
<div class="flex gap-4 bg-white/5 px-4 py-2 rounded-full border border-white/10 transition-all duration-300 focus-within:bg-white/10 focus-within:border-[#4facfe]/50 focus-within:shadow-[0_0_20px_rgba(79,172,254,0.2)]">
|
||||
|
||||
<div class="w-full flex gap-4 bg-white/5 px-4 py-2 rounded-full border border-white/10 transition-all duration-300 focus-within:bg-white/10 focus-within:border-[#4facfe]/50 focus-within:shadow-[0_0_20px_rgba(79,172,254,0.2)]">
|
||||
<input
|
||||
class="flex-1 bg-transparent border-none text-white text-base py-3 px-2 outline-none font-inherit placeholder-slate-500"
|
||||
class="flex-1 bg-transparent border-none text-white text-base py-1 px-2 outline-none font-inherit placeholder-slate-500"
|
||||
type="text"
|
||||
[formControl]="messageControl"
|
||||
(keydown.enter)="sendMessage()"
|
||||
placeholder="Type a message..."
|
||||
[disabled]="chatStore.isLoading()"
|
||||
[disabled]="messageStore.isLoading()"
|
||||
/>
|
||||
<button
|
||||
class="bg-gradient-to-br from-[#00f2fe] to-[#4facfe] border-none w-12 h-12 rounded-full flex items-center justify-center cursor-pointer text-white transition-all duration-200 hover:scale-105 hover:-translate-y-0.5 hover:shadow-[0_10px_20px_rgba(79,172,254,0.3)] active:scale-95 disabled:hover:scale-100 disabled:hover:translate-y-0 disabled:!bg-none disabled:bg-white/10 disabled:text-white/30 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
|
||||
class="bg-linear-to-br from-[#00f2fe] to-[#4facfe] border-none w-8 h-8 rounded-full flex items-center justify-center cursor-pointer text-white transition-all duration-200 hover:scale-105 hover:-translate-y-0.5 hover:shadow-[0_10px_20px_rgba(79,172,254,0.3)] active:scale-95 disabled:hover:scale-100 disabled:hover:translate-y-0 disabled:!bg-none disabled:bg-white/10 disabled:text-white/30 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-none"
|
||||
(click)="sendMessage()"
|
||||
[disabled]="!messageControl.value || chatStore.isLoading()"
|
||||
[disabled]="!messageControl.value || messageStore.isLoading()"
|
||||
>
|
||||
<svg class="w-5 h-5 fill-current" viewBox="0 0 24 24">
|
||||
<svg class="w-3 h-3 fill-current" viewBox="0 0 24 24">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@ -2,12 +2,12 @@ import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
||||
import { inject } from '@angular/core';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ChatService } from './chat-service';
|
||||
import { ChatState, Message, MessageResponse } from './chat.types';
|
||||
import { MessageState, Message, MessageResponse, ChatResponse, ChatState } from './chat.types';
|
||||
import { lastValueFrom } from 'rxjs';
|
||||
import { Router } from '@angular/router';
|
||||
import { JsonApiResource } from '../core/types/api';
|
||||
|
||||
const initialState: ChatState = {
|
||||
const initialMessageState: MessageState = {
|
||||
messages: [
|
||||
{
|
||||
id: 'null',
|
||||
@ -24,9 +24,14 @@ const initialState: ChatState = {
|
||||
id: null,
|
||||
};
|
||||
|
||||
export const ChatStore = signalStore(
|
||||
const initalChatState: ChatState = {
|
||||
chats: [],
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
export const MessageStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState(initialState),
|
||||
withState(initialMessageState),
|
||||
withMethods((store) => {
|
||||
const chatService = inject(ChatService);
|
||||
const router = inject(Router);
|
||||
@ -155,3 +160,25 @@ export const ChatStore = signalStore(
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
export const ChatStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState(initalChatState),
|
||||
withMethods((store) => {
|
||||
const chatService = inject(ChatService);
|
||||
return {
|
||||
fetchChats: async () => {
|
||||
patchState(store, { isLoading: true });
|
||||
try {
|
||||
const chats = await lastValueFrom(chatService.getChats());
|
||||
patchState(store, {
|
||||
isLoading: false,
|
||||
chats: chats.data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
patchState(store, { isLoading: false });
|
||||
}
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Component, ElementRef, ViewChild, effect, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ChatStore } from './chat.store';
|
||||
import { ChatStore, MessageStore } from './chat.store';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
@ -12,8 +12,8 @@ import { ActivatedRoute } from '@angular/router';
|
||||
styleUrl: './chat.css',
|
||||
})
|
||||
export class Chat {
|
||||
readonly chatStore = inject(ChatStore);
|
||||
private route = inject(ActivatedRoute);
|
||||
protected readonly messageStore = inject(MessageStore);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
@ViewChild('scrollContainer') private scrollContainer!: ElementRef;
|
||||
|
||||
@ -23,8 +23,8 @@ export class Chat {
|
||||
// Scroll to bottom when messages change
|
||||
effect(() => {
|
||||
// Accessing messages will trigger effect on change
|
||||
const msgs = this.chatStore.messages();
|
||||
const loading = this.chatStore.isLoading();
|
||||
const msgs = this.messageStore.messages();
|
||||
const loading = this.messageStore.isLoading();
|
||||
|
||||
setTimeout(() => {
|
||||
this.scrollToBottom();
|
||||
@ -37,8 +37,8 @@ export class Chat {
|
||||
const urlId = params.get('id');
|
||||
if (urlId) {
|
||||
// If an ID exists in the URL, populate it in the store
|
||||
this.chatStore.setChatId(urlId);
|
||||
this.chatStore.fetchChatHistory();
|
||||
this.messageStore.setChatId(urlId);
|
||||
this.messageStore.fetchChatHistory();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -47,14 +47,14 @@ export class Chat {
|
||||
|
||||
sendMessage() {
|
||||
const value = this.messageControl.value;
|
||||
if (value && value.trim() && !this.chatStore.isLoading()) {
|
||||
if (value && value.trim() && !this.messageStore.isLoading()) {
|
||||
const words = value.trim().split(/\s+/).length;
|
||||
if (words > 400) {
|
||||
this.errorMessage = `Input must be under 400 words (currently ${words} words).`;
|
||||
return;
|
||||
}
|
||||
this.errorMessage = '';
|
||||
this.chatStore.sendMessage(value.trim());
|
||||
this.messageStore.sendMessage(value.trim());
|
||||
this.messageControl.setValue('');
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import { JsonApiCollection, JsonApiDocument, JsonApiResource } from '../core/types/api';
|
||||
|
||||
export interface NewChatAttributes {
|
||||
export interface Chat {
|
||||
id: string;
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type NewChatResponse = JsonApiDocument<NewChatAttributes>;
|
||||
export type ChatResponse = JsonApiDocument<Chat>;
|
||||
export type ChatCollection = JsonApiCollection<Chat>;
|
||||
|
||||
export interface Message {
|
||||
role: 'assistant' | 'user';
|
||||
@ -19,8 +20,12 @@ export interface Message {
|
||||
export type MessageResponse = JsonApiDocument<Message>;
|
||||
export type MessageCollection = JsonApiCollection<Message>;
|
||||
|
||||
export type ChatState = {
|
||||
export type MessageState = {
|
||||
messages: JsonApiResource<Message>[];
|
||||
isLoading: boolean;
|
||||
id: string | null;
|
||||
};
|
||||
export type ChatState = {
|
||||
chats: JsonApiResource<Chat>[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { AuthStore } from '../../../auth/auth.store';
|
||||
import { ChatStore } from '../../../chat/chat.store';
|
||||
import { MessageStore } from '../../../chat/chat.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
@ -11,5 +11,5 @@ import { ChatStore } from '../../../chat/chat.store';
|
||||
})
|
||||
export class Header {
|
||||
protected readonly authStore = inject(AuthStore);
|
||||
protected readonly chatStore = inject(ChatStore);
|
||||
protected readonly chatStore = inject(MessageStore);
|
||||
}
|
||||
|
||||
20
frontend/src/app/core/layout/sidebar/sidebar.css
Normal file
20
frontend/src/app/core/layout/sidebar/sidebar.css
Normal file
@ -0,0 +1,20 @@
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: #1e2f4d;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: #2a3f60;
|
||||
}
|
||||
230
frontend/src/app/core/layout/sidebar/sidebar.html
Normal file
230
frontend/src/app/core/layout/sidebar/sidebar.html
Normal file
@ -0,0 +1,230 @@
|
||||
<button
|
||||
*ngIf="!isOpen()"
|
||||
(click)="toggleSidebar()"
|
||||
class="fixed top-4 right-4 z-50 p-2.5 rounded-xl bg-[#1a2744] border border-[#2a3a5c] text-[#7b9cc4] hover:text-white hover:bg-[#243058] hover:border-[#3d5a8a] transition-all duration-200 shadow-lg"
|
||||
title="Open sidebar"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Backdrop (mobile) -->
|
||||
<div
|
||||
*ngIf="isOpen()"
|
||||
class="fixed inset-0 bg-black/40 backdrop-blur-sm z-30 lg:hidden"
|
||||
(click)="toggleSidebar()"
|
||||
></div>
|
||||
|
||||
<aside [class]="sidebarClasses()" style="font-family: 'DM Sans', 'Segoe UI', sans-serif;">
|
||||
<div
|
||||
class="absolute top-0 left-0 right-0 h-px bg-linear-to-r from-transparent via-[#3d6cb5] to-transparent"
|
||||
></div>
|
||||
|
||||
<div class="flex items-center justify-between px-4 pt-5 pb-4 border-b border-[#1e2f4d]">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-linear-to-br from-[#00f2fe] to-[#4facfe] flex items-center justify-center font-semibold text-xl shadow-[0_0_20px_rgba(79,172,254,0.4)] animate-[pulse_2s_infinite]">
|
||||
AI
|
||||
</div>
|
||||
<div>
|
||||
<h1
|
||||
class="m-0 font-semibold tracking-wide bg-linear-to-r from-white to-indigo-300 bg-clip-text text-transparent">
|
||||
Post Assistant</h1>
|
||||
<p class="m-0 text-xs text-slate-400">Always online, ready to write.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
(click)="toggleSidebar()"
|
||||
class="p-1.5 rounded-lg text-[#4a6080] hover:text-[#8ab0d8] hover:bg-[#1a2a48] transition-all duration-150"
|
||||
title="Close sidebar"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-3 pt-3 pb-2">
|
||||
<button
|
||||
(click)="newChat()"
|
||||
class="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-[#2d5be3] hover:bg-[#3468f0] text-white text-sm font-medium transition-all duration-200 shadow-md hover:shadow-[#2d5be3]/30 hover:shadow-lg active:scale-[0.98]"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-3 pb-3">
|
||||
<div class="relative">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-[#4a6080]"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search chats..."
|
||||
(input)="onSearch($event)"
|
||||
class="w-full pl-9 pr-3 py-2 text-xs bg-[#111a2e] border border-[#1e2f4d] rounded-lg text-[#8ab0d8] placeholder-[#3a5070] focus:outline-none focus:border-[#2d5be3] focus:ring-1 focus:ring-[#2d5be3]/30 transition-all duration-150"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-2 pb-4 scrollbar-thin">
|
||||
<!-- Chat Items -->
|
||||
<div class="space-y-0.5">
|
||||
@for (chat of chatStore.chats(); track chat.id) {
|
||||
<button
|
||||
(click)="selectChat(chat)"
|
||||
[class]="chatItemClasses(chat)"
|
||||
>
|
||||
<!-- Active indicator -->
|
||||
<div
|
||||
class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-6 bg-[#2d5be3] rounded-r-full"
|
||||
></div>
|
||||
|
||||
<div class="flex items-start gap-2.5 w-full min-w-0">
|
||||
<!-- Icon -->
|
||||
<div [class]="chatIconClasses(chat)">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-3 h-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-3 3-3-3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<p
|
||||
class="text-xs font-medium truncate text-white"
|
||||
>
|
||||
{{ chat.attributes.title }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Time -->
|
||||
<span class="text-[9px] text-[#3a5272] shrink-0 mt-0.5">{{
|
||||
formatTime(chat.attributes.createdAt)
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
@if (chatStore.chats().length === 0) {
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-10 px-4 text-center"
|
||||
>
|
||||
<div class="w-10 h-10 rounded-full bg-[#111a2e] flex items-center justify-center mb-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5 text-[#3a5272]"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xs text-[#3a5272]">No chats found</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-[#1e2f4d] px-3 py-3">
|
||||
<div
|
||||
class="flex items-center gap-2.5 px-2 py-2 rounded-xl hover:bg-[#111a2e] transition-all duration-150 cursor-pointer group"
|
||||
>
|
||||
<div
|
||||
class="w-7 h-7 rounded-lg bg-linear-to-br from-[#2d5be3] to-[#1a3a9e] flex items-center justify-center text-white text-xs font-bold shrink-0"
|
||||
>
|
||||
K
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium text-[#c8d8f0] truncate">Kushal Saha</p>
|
||||
<p class="text-[10px] text-[#3a5272] truncate">Free Plan</p>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-3.5 h-3.5 text-[#3a5272] group-hover:text-[#5a80a8] transition-colors"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
22
frontend/src/app/core/layout/sidebar/sidebar.spec.ts
Normal file
22
frontend/src/app/core/layout/sidebar/sidebar.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { Sidebar } from './sidebar';
|
||||
|
||||
describe('Sidebar', () => {
|
||||
let component: Sidebar;
|
||||
let fixture: ComponentFixture<Sidebar>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Sidebar],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(Sidebar);
|
||||
component = fixture.componentInstance;
|
||||
await fixture.whenStable();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
78
frontend/src/app/core/layout/sidebar/sidebar.ts
Normal file
78
frontend/src/app/core/layout/sidebar/sidebar.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { Component, signal, computed, inject, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { ChatStore } from '../../../chat/chat.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterModule],
|
||||
templateUrl: 'sidebar.html',
|
||||
styleUrl: 'sidebar.css',
|
||||
})
|
||||
export class Sidebar implements OnInit {
|
||||
protected chatStore = inject(ChatStore);
|
||||
protected isOpen = signal(true);
|
||||
protected searchQuery = signal('');
|
||||
|
||||
ngOnInit() {
|
||||
this.chatStore.fetchChats();
|
||||
console.log(this.chatStore.chats());
|
||||
}
|
||||
|
||||
protected sidebarClasses = computed(() => {
|
||||
const base =
|
||||
'fixed top-0 right-0 h-full w-64 border-l border-[#1e2f4d] flex flex-col z-40 transition-transform duration-300 ease-in-out shadow-2xl shadow-black/40 relative overflow-hidden';
|
||||
return this.isOpen() ? base : base + ' translate-x-full';
|
||||
});
|
||||
|
||||
protected chatItemClasses(chat: any): string {
|
||||
const base =
|
||||
'relative w-full flex items-center px-2 py-2 rounded-lg transition-all duration-150 cursor-pointer group';
|
||||
return chat.isActive
|
||||
? base + ' bg-[#13213d] text-white'
|
||||
: base + ' hover:bg-[#111a2e] text-[#6a8faf]';
|
||||
}
|
||||
|
||||
protected chatIconClasses(chat: any): string {
|
||||
const base = 'w-6 h-6 rounded-md flex items-center justify-center shrink-0 mt-0.5';
|
||||
return chat.isActive
|
||||
? base + ' bg-[#2d5be3]/20 text-[#5b8af0]'
|
||||
: base + ' bg-[#111a2e] text-[#3a5272] group-hover:bg-[#1a2a48] group-hover:text-[#5a80a8]';
|
||||
}
|
||||
|
||||
protected toggleSidebar(): void {
|
||||
this.isOpen.update((v) => !v);
|
||||
}
|
||||
|
||||
protected newChat(): void {
|
||||
console.log('New chat triggered');
|
||||
}
|
||||
|
||||
protected selectChat(chat: any): void {
|
||||
// this.activeChatId.set(chat.id);
|
||||
// this.sections.update((sections) =>
|
||||
// sections.map((section) => ({
|
||||
// ...section,
|
||||
// chats: section.chats.map((c) => ({ ...c, isActive: c.id === chat.id })),
|
||||
// })),
|
||||
// );
|
||||
}
|
||||
|
||||
protected onSearch(event: Event): void {
|
||||
this.searchQuery.set((event.target as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
protected formatTime(dateValue: string | Date): string {
|
||||
const date = new Date(dateValue);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(mins / 60);
|
||||
|
||||
if (mins < 1) return 'now';
|
||||
if (mins < 60) return `${mins}m`;
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,6 @@
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
</head>
|
||||
<body class="min-h-screen antialiased">
|
||||
<app-root></app-root>
|
||||
<app-root class="w-full" ></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
color: white;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user