diff --git a/backend/app/Actions/Chats/GetUserChatsAction.php b/backend/app/Actions/Chats/GetUserChatsAction.php new file mode 100644 index 0000000..73bb134 --- /dev/null +++ b/backend/app/Actions/Chats/GetUserChatsAction.php @@ -0,0 +1,18 @@ +chats()->latest()->paginate(); + + return $chats->through(fn ($chat) => ChatResponseDto::fromModel($chat)); + } +} diff --git a/backend/app/Http/Controllers/Chats/ChatController.php b/backend/app/Http/Controllers/Chats/ChatController.php index 8e2cea2..952352d 100644 --- a/backend/app/Http/Controllers/Chats/ChatController.php +++ b/backend/app/Http/Controllers/Chats/ChatController.php @@ -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) { diff --git a/backend/app/Http/Resources/Chats/ChatCollection.php b/backend/app/Http/Resources/Chats/ChatCollection.php new file mode 100644 index 0000000..a68bca5 --- /dev/null +++ b/backend/app/Http/Resources/Chats/ChatCollection.php @@ -0,0 +1,23 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'data' => $this->collection, + ]; + } +} diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index aba7bee..ae848de 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -1,2 +1,8 @@ - - +
+
+
+ +
+
+ +
diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index 04f6d3b..078801e 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,12 +1,13 @@ import { Component, signal } from '@angular/core'; 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({ 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'); diff --git a/frontend/src/app/chat/chat-service.ts b/frontend/src/app/chat/chat-service.ts index 6db514a..7829d3a 100644 --- a/frontend/src/app/chat/chat-service.ts +++ b/frontend/src/app/chat/chat-service.ts @@ -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(`${this.apiUrl}/chats`, {}); + return this.http.post(`${this.apiUrl}/chats`, {}); } public sendMessage(id: string, message: string) { @@ -23,4 +23,8 @@ export class ChatService { public getMessages(id: string) { return this.http.get(`${this.apiUrl}/chats/${id}/messages`); } + + public getChats() { + return this.http.get(`${this.apiUrl}/chats`); + } } diff --git a/frontend/src/app/chat/chat.html b/frontend/src/app/chat/chat.html index 68a17a4..fac9fd8 100644 --- a/frontend/src/app/chat/chat.html +++ b/frontend/src/app/chat/chat.html @@ -1,21 +1,13 @@ -
-
-
AI
-
-

Post Assistant

-

Always online, ready to write.

-
-
- +
- @for (msg of chatStore.messages(); track msg.id) { + @for (msg of messageStore.messages(); track msg.id) {
{{ msg.attributes.content }}
@@ -25,7 +17,7 @@
} - @if (chatStore.isLoading()) { + @if (messageStore.isLoading()) {
@@ -34,27 +26,28 @@ }
-
+
@if (errorMessage) {
{{ errorMessage }}
} -
+ +
diff --git a/frontend/src/app/chat/chat.store.ts b/frontend/src/app/chat/chat.store.ts index eca5060..f263325 100644 --- a/frontend/src/app/chat/chat.store.ts +++ b/frontend/src/app/chat/chat.store.ts @@ -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 }); + } + }, + }; + }), +); diff --git a/frontend/src/app/chat/chat.ts b/frontend/src/app/chat/chat.ts index 534f41a..2cf11c0 100644 --- a/frontend/src/app/chat/chat.ts +++ b/frontend/src/app/chat/chat.ts @@ -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(''); } } diff --git a/frontend/src/app/chat/chat.types.ts b/frontend/src/app/chat/chat.types.ts index 7faa03f..952e8f3 100644 --- a/frontend/src/app/chat/chat.types.ts +++ b/frontend/src/app/chat/chat.types.ts @@ -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; +export type ChatResponse = JsonApiDocument; +export type ChatCollection = JsonApiCollection; export interface Message { role: 'assistant' | 'user'; @@ -19,8 +20,12 @@ export interface Message { export type MessageResponse = JsonApiDocument; export type MessageCollection = JsonApiCollection; -export type ChatState = { +export type MessageState = { messages: JsonApiResource[]; isLoading: boolean; id: string | null; }; +export type ChatState = { + chats: JsonApiResource[]; + isLoading: boolean; +}; diff --git a/frontend/src/app/core/layout/header/header.ts b/frontend/src/app/core/layout/header/header.ts index 557d802..15d1cd5 100644 --- a/frontend/src/app/core/layout/header/header.ts +++ b/frontend/src/app/core/layout/header/header.ts @@ -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); } diff --git a/frontend/src/app/core/layout/sidebar/sidebar.css b/frontend/src/app/core/layout/sidebar/sidebar.css new file mode 100644 index 0000000..0758929 --- /dev/null +++ b/frontend/src/app/core/layout/sidebar/sidebar.css @@ -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; +} diff --git a/frontend/src/app/core/layout/sidebar/sidebar.html b/frontend/src/app/core/layout/sidebar/sidebar.html new file mode 100644 index 0000000..131bbcb --- /dev/null +++ b/frontend/src/app/core/layout/sidebar/sidebar.html @@ -0,0 +1,230 @@ + + + +
+ + diff --git a/frontend/src/app/core/layout/sidebar/sidebar.spec.ts b/frontend/src/app/core/layout/sidebar/sidebar.spec.ts new file mode 100644 index 0000000..2f291a9 --- /dev/null +++ b/frontend/src/app/core/layout/sidebar/sidebar.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Sidebar } from './sidebar'; + +describe('Sidebar', () => { + let component: Sidebar; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Sidebar], + }).compileComponents(); + + fixture = TestBed.createComponent(Sidebar); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/core/layout/sidebar/sidebar.ts b/frontend/src/app/core/layout/sidebar/sidebar.ts new file mode 100644 index 0000000..ec51625 --- /dev/null +++ b/frontend/src/app/core/layout/sidebar/sidebar.ts @@ -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`; + } +} diff --git a/frontend/src/index.html b/frontend/src/index.html index 06cfd45..ec0b4ee 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -8,6 +8,6 @@ - + diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 0ed28b4..cbf1297 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -3,6 +3,7 @@ @import 'tailwindcss'; body { + width: 100%; color: white; margin: 0; padding: 0;