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:
kushal-saha 2026-04-30 13:06:47 +00:00
parent 20a56d4adc
commit ad0f0bf67b
17 changed files with 484 additions and 50 deletions

View 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));
}
}

View File

@ -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)
{

View 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,
];
}
}

View File

@ -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>

View File

@ -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');

View File

@ -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`);
}
}

View File

@ -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>

View File

@ -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 });
}
},
};
}),
);

View File

@ -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('');
}
}

View File

@ -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;
};

View File

@ -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);
}

View 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;
}

View 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>

View 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();
});
});

View 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`;
}
}

View File

@ -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>

View File

@ -3,6 +3,7 @@
@import 'tailwindcss';
body {
width: 100%;
color: white;
margin: 0;
padding: 0;