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;
|
namespace App\Http\Controllers\Chats;
|
||||||
|
|
||||||
use App\Actions\Chats\CreateChatAction;
|
use App\Actions\Chats\CreateChatAction;
|
||||||
|
use App\Actions\Chats\GetUserChatsAction;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Chats\CreateChatRequest;
|
use App\Http\Requests\Chats\CreateChatRequest;
|
||||||
use App\Http\Resources\Chats\ChatResponseResource;
|
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
|
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)
|
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 />
|
<div class="flex h-screen w-full">
|
||||||
<router-outlet />
|
<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 { Component, signal } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import {Header} from './core/layout/header/header';
|
import { Header } from './core/layout/header/header';
|
||||||
|
import { Sidebar } from './core/layout/sidebar/sidebar';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [RouterOutlet, Header],
|
imports: [RouterOutlet, Header, Sidebar],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.css'
|
styleUrl: './app.css',
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
protected readonly title = signal('frontend');
|
protected readonly title = signal('frontend');
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { API_URL } from '../core/tokens/api-urls';
|
import { API_URL } from '../core/tokens/api-urls';
|
||||||
import { MessageCollection, MessageResponse, NewChatResponse } from './chat.types';
|
import { ChatCollection, MessageCollection, MessageResponse, ChatResponse } from './chat.types';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@ -11,7 +11,7 @@ export class ChatService {
|
|||||||
private apiUrl = inject(API_URL);
|
private apiUrl = inject(API_URL);
|
||||||
|
|
||||||
public newChat() {
|
public newChat() {
|
||||||
return this.http.post<NewChatResponse>(`${this.apiUrl}/chats`, {});
|
return this.http.post<ChatResponse>(`${this.apiUrl}/chats`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendMessage(id: string, message: string) {
|
public sendMessage(id: string, message: string) {
|
||||||
@ -23,4 +23,8 @@ export class ChatService {
|
|||||||
public getMessages(id: string) {
|
public getMessages(id: string) {
|
||||||
return this.http.get<MessageCollection>(`${this.apiUrl}/chats/${id}/messages`);
|
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="h-screen flex flex-col bg-white/5 backdrop-blur-[20px] border border-white/10 relative">
|
||||||
<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="flex-1 overflow-y-auto p-8 flex flex-col gap-6 scroll-smooth custom-scrollbar" #scrollContainer>
|
<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
|
<div
|
||||||
class="flex flex-col max-w-[80%] animate-[messageAppear_0.5s_cubic-bezier(0.16,1,0.3,1)]"
|
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'"
|
[ngClass]="msg.attributes.role === 'user' ? 'self-end items-end' : 'self-start items-start'"
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
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-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'"
|
[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 }}
|
{{ msg.attributes.content }}
|
||||||
</div>
|
</div>
|
||||||
@ -25,7 +17,7 @@
|
|||||||
</div>
|
</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="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.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>
|
<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>
|
||||||
|
|
||||||
<div class="px-8 py-6 bg-black/30 border-t border-white/5">
|
<div class="px-8 py-3">
|
||||||
@if (errorMessage) {
|
@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]">
|
<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 }}
|
{{ errorMessage }}
|
||||||
</div>
|
</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
|
<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"
|
type="text"
|
||||||
[formControl]="messageControl"
|
[formControl]="messageControl"
|
||||||
(keydown.enter)="sendMessage()"
|
(keydown.enter)="sendMessage()"
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message..."
|
||||||
[disabled]="chatStore.isLoading()"
|
[disabled]="messageStore.isLoading()"
|
||||||
/>
|
/>
|
||||||
<button
|
<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()"
|
(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"/>
|
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -2,12 +2,12 @@ import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { HttpErrorResponse } from '@angular/common/http';
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { ChatService } from './chat-service';
|
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 { lastValueFrom } from 'rxjs';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { JsonApiResource } from '../core/types/api';
|
import { JsonApiResource } from '../core/types/api';
|
||||||
|
|
||||||
const initialState: ChatState = {
|
const initialMessageState: MessageState = {
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
id: 'null',
|
id: 'null',
|
||||||
@ -24,9 +24,14 @@ const initialState: ChatState = {
|
|||||||
id: null,
|
id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChatStore = signalStore(
|
const initalChatState: ChatState = {
|
||||||
|
chats: [],
|
||||||
|
isLoading: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MessageStore = signalStore(
|
||||||
{ providedIn: 'root' },
|
{ providedIn: 'root' },
|
||||||
withState(initialState),
|
withState(initialMessageState),
|
||||||
withMethods((store) => {
|
withMethods((store) => {
|
||||||
const chatService = inject(ChatService);
|
const chatService = inject(ChatService);
|
||||||
const router = inject(Router);
|
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 { Component, ElementRef, ViewChild, effect, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { ChatStore } from './chat.store';
|
import { ChatStore, MessageStore } from './chat.store';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -12,8 +12,8 @@ import { ActivatedRoute } from '@angular/router';
|
|||||||
styleUrl: './chat.css',
|
styleUrl: './chat.css',
|
||||||
})
|
})
|
||||||
export class Chat {
|
export class Chat {
|
||||||
readonly chatStore = inject(ChatStore);
|
protected readonly messageStore = inject(MessageStore);
|
||||||
private route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
@ViewChild('scrollContainer') private scrollContainer!: ElementRef;
|
@ViewChild('scrollContainer') private scrollContainer!: ElementRef;
|
||||||
|
|
||||||
@ -23,8 +23,8 @@ export class Chat {
|
|||||||
// Scroll to bottom when messages change
|
// Scroll to bottom when messages change
|
||||||
effect(() => {
|
effect(() => {
|
||||||
// Accessing messages will trigger effect on change
|
// Accessing messages will trigger effect on change
|
||||||
const msgs = this.chatStore.messages();
|
const msgs = this.messageStore.messages();
|
||||||
const loading = this.chatStore.isLoading();
|
const loading = this.messageStore.isLoading();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.scrollToBottom();
|
this.scrollToBottom();
|
||||||
@ -37,8 +37,8 @@ export class Chat {
|
|||||||
const urlId = params.get('id');
|
const urlId = params.get('id');
|
||||||
if (urlId) {
|
if (urlId) {
|
||||||
// If an ID exists in the URL, populate it in the store
|
// If an ID exists in the URL, populate it in the store
|
||||||
this.chatStore.setChatId(urlId);
|
this.messageStore.setChatId(urlId);
|
||||||
this.chatStore.fetchChatHistory();
|
this.messageStore.fetchChatHistory();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -47,14 +47,14 @@ export class Chat {
|
|||||||
|
|
||||||
sendMessage() {
|
sendMessage() {
|
||||||
const value = this.messageControl.value;
|
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;
|
const words = value.trim().split(/\s+/).length;
|
||||||
if (words > 400) {
|
if (words > 400) {
|
||||||
this.errorMessage = `Input must be under 400 words (currently ${words} words).`;
|
this.errorMessage = `Input must be under 400 words (currently ${words} words).`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.chatStore.sendMessage(value.trim());
|
this.messageStore.sendMessage(value.trim());
|
||||||
this.messageControl.setValue('');
|
this.messageControl.setValue('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import { JsonApiCollection, JsonApiDocument, JsonApiResource } from '../core/types/api';
|
import { JsonApiCollection, JsonApiDocument, JsonApiResource } from '../core/types/api';
|
||||||
|
|
||||||
export interface NewChatAttributes {
|
export interface Chat {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
createdAt: string;
|
createdAt: Date;
|
||||||
updatedAt: string;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NewChatResponse = JsonApiDocument<NewChatAttributes>;
|
export type ChatResponse = JsonApiDocument<Chat>;
|
||||||
|
export type ChatCollection = JsonApiCollection<Chat>;
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
role: 'assistant' | 'user';
|
role: 'assistant' | 'user';
|
||||||
@ -19,8 +20,12 @@ export interface Message {
|
|||||||
export type MessageResponse = JsonApiDocument<Message>;
|
export type MessageResponse = JsonApiDocument<Message>;
|
||||||
export type MessageCollection = JsonApiCollection<Message>;
|
export type MessageCollection = JsonApiCollection<Message>;
|
||||||
|
|
||||||
export type ChatState = {
|
export type MessageState = {
|
||||||
messages: JsonApiResource<Message>[];
|
messages: JsonApiResource<Message>[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
id: string | null;
|
id: string | null;
|
||||||
};
|
};
|
||||||
|
export type ChatState = {
|
||||||
|
chats: JsonApiResource<Chat>[];
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { AuthStore } from '../../../auth/auth.store';
|
import { AuthStore } from '../../../auth/auth.store';
|
||||||
import { ChatStore } from '../../../chat/chat.store';
|
import { MessageStore } from '../../../chat/chat.store';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-header',
|
selector: 'app-header',
|
||||||
@ -11,5 +11,5 @@ import { ChatStore } from '../../../chat/chat.store';
|
|||||||
})
|
})
|
||||||
export class Header {
|
export class Header {
|
||||||
protected readonly authStore = inject(AuthStore);
|
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">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen antialiased">
|
<body class="min-h-screen antialiased">
|
||||||
<app-root></app-root>
|
<app-root class="w-full" ></app-root>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
width: 100%;
|
||||||
color: white;
|
color: white;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user