Compare commits

...

3 Commits

Author SHA1 Message Date
cdfa1da90a modular responses 2025-06-04 19:32:56 +02:00
185f713aeb feat: enhance chat module with material badge and user model, update channel entry component for alerts 2025-06-03 20:38:51 +02:00
ab46e2dd1e refactor: remove unused toolbar components and improve toast notifications
- Removed MatToolbar and MatIcon imports from app.component.ts and updated app.component.html.
- Reduced toast notification timeout from 3000ms to 1000ms in app.config.ts.
- Changed default redirect path from 'auth' to 'chat' in app.routes.ts.
- Added ToolbarComponent to AuthModule and ChatModule.
- Updated login and register components to include the toolbar.
- Simplified toast notifications in login and register components.
- Implemented chat service to manage channels and messages.
- Created message and profile picture components for chat feed.
- Enhanced channel list component to handle selected channels.
- Improved feed component to display messages based on selected channel.
2025-06-03 20:20:52 +02:00
36 changed files with 354 additions and 82 deletions

View File

@ -1,8 +1 @@
<mat-toolbar> <router-outlet />
<mat-toolbar-row>
<mat-icon>chat</mat-icon>
<span>Chat</span>
</mat-toolbar-row>
</mat-toolbar>
<router-outlet />

View File

@ -1,12 +1,12 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, MatToolbarModule, MatIconModule], imports: [RouterOutlet],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss' styleUrl: './app.component.scss'
}) })
export class AppComponent { } export class AppComponent {
}

View File

@ -13,8 +13,9 @@ export const appConfig: ApplicationConfig = {
provideHttpClient(), provideHttpClient(),
provideAnimations(), provideAnimations(),
provideToastr({ provideToastr({
timeOut: 3000, timeOut: 1000,
positionClass: 'toast-top-right', positionClass: 'toast-top-right',
closeButton: true
}), }),
] ]
}; };

View File

@ -11,6 +11,6 @@ export const routes: Routes = [
loadChildren: () => import('./chat/chat.module').then(m => m.ChatModule), loadChildren: () => import('./chat/chat.module').then(m => m.ChatModule),
canActivate: [IsLoggedInCanActivate] canActivate: [IsLoggedInCanActivate]
}, },
{ path: '', redirectTo: 'auth', pathMatch: 'full' }, { path: '', redirectTo: 'chat', pathMatch: 'full' },
{ path: '**/*', redirectTo: '', pathMatch: 'full' } { path: '**/*', redirectTo: '', pathMatch: 'full' }
]; ];

View File

@ -10,6 +10,7 @@ import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { ToolbarComponent } from "../common/toolbar/toolbar.component";
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -24,7 +25,8 @@ import { MatButtonModule } from '@angular/material/button';
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatButtonModule, MatButtonModule,
ReactiveFormsModule ReactiveFormsModule,
] ToolbarComponent
]
}) })
export class AuthModule { } export class AuthModule { }

View File

@ -1,3 +1,5 @@
<app-toolbar></app-toolbar>
<mat-card class="card"> <mat-card class="card">
<form [formGroup]="loginForm" (ngSubmit)="login()"> <form [formGroup]="loginForm" (ngSubmit)="login()">

View File

@ -35,23 +35,14 @@ export class LoginComponent implements OnInit {
).subscribe({ ).subscribe({
next: result => { next: result => {
if (result.error) { if (result.error) {
this.toastrService.error(result.error, "Error", { this.toastrService.error(result.error, "Error");
timeOut: 3000,
closeButton: true
});
} else { } else {
this.toastrService.info(result.message, "Success", { this.toastrService.info(result.message, "Success");
timeOut: 3000,
closeButton: true
});
this.router.navigateByUrl('chat'); this.router.navigateByUrl('chat');
} }
}, },
error: _ => { error: _ => {
this.toastrService.error("API error", "Error", { this.toastrService.error("API error", "Error");
timeOut: 3000,
closeButton: true
});
} }
}) })
} }

View File

@ -1,3 +1,5 @@
<app-toolbar></app-toolbar>
<mat-card class="card"> <mat-card class="card">
<form [formGroup]="registerForm" (ngSubmit)="register()"> <form [formGroup]="registerForm" (ngSubmit)="register()">

View File

@ -49,23 +49,14 @@ export class RegisterComponent {
).subscribe({ ).subscribe({
next: result => { next: result => {
if (result.error) { if (result.error) {
this.toastrService.error(result.error, "Error", { this.toastrService.error(result.error, "Error");
timeOut: 3000,
closeButton: true
});
} else { } else {
this.toastrService.info(result.message, "Success", { this.toastrService.info(result.message, "Success");
timeOut: 3000,
closeButton: true
});
this.router.navigateByUrl('/auth/login'); this.router.navigateByUrl('/auth/login');
} }
}, },
error: _ => { error: _ => {
this.toastrService.error("API error", "Error", { this.toastrService.error("API error", "Error");
timeOut: 3000,
closeButton: true
});
} }
}) })
} }

View File

@ -6,18 +6,25 @@ import { ChatComponent } from './chat/chat.component';
import { ChannelListComponent } from './chat/channel-list/channel-list.component'; import { ChannelListComponent } from './chat/channel-list/channel-list.component';
import { FeedComponent } from './chat/feed/feed.component'; import { FeedComponent } from './chat/feed/feed.component';
import { ChannelEntryComponent } from './chat/channel-list/channel-entry/channel-entry.component'; import { ChannelEntryComponent } from './chat/channel-list/channel-entry/channel-entry.component';
import { MessageComponent } from './chat/feed/message/message.component';
import { ProfilePictureComponent } from './chat/feed/message/profile-picture/profile-picture.component';
import { ToolbarComponent } from '../common/toolbar/toolbar.component';
import { MatBadgeModule } from '@angular/material/badge';
@NgModule({ @NgModule({
declarations: [ declarations: [
ChatComponent, ChatComponent,
ChannelListComponent, ChannelListComponent,
FeedComponent, FeedComponent,
ChannelEntryComponent ChannelEntryComponent,
MessageComponent,
ProfilePictureComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
ChatRoutingModule ChatRoutingModule,
ToolbarComponent,
MatBadgeModule
] ]
}) })
export class ChatModule { } export class ChatModule { }

View File

@ -1 +1,7 @@
<div [title]="channel.description" [class.selected]="selected">{{channel.name}}</div> <div [title]="channel.description" [class.selected]="selected">
<span [matBadge]="this.hasAlert ? '1' : ''" matBadgeSize="small">
{{channel.name}}
</span>
</div>

View File

@ -5,6 +5,6 @@ div {
.selected { .selected {
color: var(--mat-sys-on-secondary); color: var(--mat-sys-on-secondary);
background-color: var(--mat-sys-secondary); background: linear-gradient(to right, var(--mat-sys-secondary), var(--mat-sys-secondary-container) 90%, rgba(0,0,0,0));
transition: all .3s ease-in-out; transition: all .3s ease-in-out;
} }

View File

@ -1,4 +1,4 @@
import { Component, Input } from '@angular/core'; import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { Channel } from '../../../../models/channel'; import { Channel } from '../../../../models/channel';
@Component({ @Component({
@ -7,7 +7,18 @@ import { Channel } from '../../../../models/channel';
templateUrl: './channel-entry.component.html', templateUrl: './channel-entry.component.html',
styleUrl: './channel-entry.component.scss' styleUrl: './channel-entry.component.scss'
}) })
export class ChannelEntryComponent { export class ChannelEntryComponent implements OnChanges {
@Input("channel") public channel!: Channel; @Input("channel") public channel!: Channel;
@Input("selected") public selected!: boolean; @Input("selected") public selected!: boolean;
public hasAlert: boolean = false;
ngOnChanges(changes: SimpleChanges): void {
if (this.selected) {
this.hasAlert = false;
}
}
// TODO: subsribe to message alerts and display them
// unsubscirbe when leaving
} }

View File

@ -2,5 +2,5 @@
*ngFor="let channel of this.channels; let i = index" *ngFor="let channel of this.channels; let i = index"
[channel]="channels[i]" [channel]="channels[i]"
(click)="Select(i)" (click)="Select(i)"
[selected]="channels[i].id == selectedChannel.id" [selected]="channels[i].id == selectedChannel?.id"
/> />

View File

@ -1,5 +1,6 @@
import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, OnChanges, OnInit, Output } from '@angular/core';
import { Channel } from '../../../models/channel'; import { Channel } from '../../../models/channel';
import { ChatService } from '../../../services/chat.service';
@Component({ @Component({
selector: 'app-channel-list', selector: 'app-channel-list',
@ -10,27 +11,17 @@ import { Channel } from '../../../models/channel';
export class ChannelListComponent implements OnInit { export class ChannelListComponent implements OnInit {
@Output("select") selectEmitter: EventEmitter<Channel> = new EventEmitter<Channel>(); @Output("select") selectEmitter: EventEmitter<Channel> = new EventEmitter<Channel>();
public channels: Channel[] = [ public channels!: Channel[];
{ public selectedChannel?: Channel;
id: 0,
name: 'default',
description: 'this is the default channel'
},
{
id: 1,
name: 'gaming',
description: 'this is another channel'
},
];
public selectedChannel!: Channel; constructor(private chatService: ChatService) { }
constructor() { }
ngOnInit() { ngOnInit() {
// TODO: query list of channels this.chatService.ListChannels()
.subscribe(channels => {
this.selectedChannel = this.channels[0]; this.channels = channels;
this.selectedChannel = this.channels[0];
});
} }
public Select(index: number): void { public Select(index: number): void {

View File

@ -1,4 +1,6 @@
<app-toolbar />
<div id="container"> <div id="container">
<app-channel-list (select)="Select($event)"></app-channel-list> <app-channel-list (select)="Select($event)"></app-channel-list>
<app-feed></app-feed> <app-feed [channel]="this.selectedChannel"></app-feed>
</div> </div>

View File

@ -8,7 +8,9 @@ import { Channel } from '../../models/channel';
styleUrl: './chat.component.scss' styleUrl: './chat.component.scss'
}) })
export class ChatComponent { export class ChatComponent {
public selectedChannel?: Channel;
public Select(channel: Channel): void { public Select(channel: Channel): void {
// TODO: update feed this.selectedChannel = channel;
} }
} }

View File

@ -1 +1,4 @@
<p>feed works!</p> <app-message
*ngFor="let message of this.messages"
[message]="message"
/>

View File

@ -0,0 +1,8 @@
:host {
color: var(--mat-sys-on-background);
background-color: var(--mat-sys-background);
border-left: 2px solid var(--mat-sys-secondary);
border-top: 2px solid var(--mat-sys-secondary);
border-radius: 10px 0;
}

View File

@ -1,4 +1,8 @@
import { Component } from '@angular/core'; import { Component, Input, OnChanges, OnInit } from '@angular/core';
import { Message } from '../../../models/message';
import { ChatService } from '../../../services/chat.service';
import { UserService } from '../../../services/user.service';
import { Channel } from '../../../models/channel';
@Component({ @Component({
selector: 'app-feed', selector: 'app-feed',
@ -6,6 +10,18 @@ import { Component } from '@angular/core';
templateUrl: './feed.component.html', templateUrl: './feed.component.html',
styleUrl: './feed.component.scss' styleUrl: './feed.component.scss'
}) })
export class FeedComponent { export class FeedComponent implements OnChanges {
private readonly DEFAULT_CHANNEL_ID: number = 1;
@Input('channel') public channel?: Channel;
public messages!: Message[];
constructor(private chatService: ChatService, private userService: UserService) { }
ngOnChanges() {
this.chatService.GetMessages(this.channel?.id ?? this.DEFAULT_CHANNEL_ID)
.subscribe(messages => {
this.messages = messages;
});
}
} }

View File

@ -0,0 +1,9 @@
<div class="message-container">
<app-profile-picture [username]="this.message.sender" />
<div class="message-inner-container">
<div class="message-sender">{{this.message.sender}}</div>
<div class="message-content">{{this.message.content}}</div>
</div>
</div>

View File

@ -0,0 +1,35 @@
app-profile-picture {
display: inline-block;
width: 50px;
height: 50px;
margin: 10px;
}
.message-container {
display: grid;
grid-template-columns: 70px 1fr;
grid-template-rows: 1fr;
grid-column-gap: 0px;
grid-row-gap: 0px;
.message-inner-container {
margin-top: 10px;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1.5rem 1fr;
grid-column-gap: 0px;
grid-row-gap: 0px;
height: fit-content;
.message-sender {
vertical-align: top;
}
.message-content {
height: fit-content;
}
}
}

View File

@ -0,0 +1,12 @@
import { Component, Input } from '@angular/core';
import { Message } from '../../../../models/message';
@Component({
selector: 'app-message',
standalone: false,
templateUrl: './message.component.html',
styleUrl: './message.component.scss'
})
export class MessageComponent {
@Input('message') public message!: Message;
}

View File

@ -0,0 +1 @@
<img [src]="this.url" [alt]="this.username">

View File

@ -0,0 +1,7 @@
img {
display: inline-block;
width: 100%;
border-radius: 50%;
box-shadow: 0px 0px 5px rgba(0, 0, 0, 1);
}

View File

@ -0,0 +1,20 @@
import { Component, Input, OnInit } from '@angular/core';
import { UserService } from '../../../../../services/user.service';
@Component({
selector: 'app-profile-picture',
standalone: false,
templateUrl: './profile-picture.component.html',
styleUrl: './profile-picture.component.scss'
})
export class ProfilePictureComponent implements OnInit {
@Input("username") public username!: string;
public url?: string;
constructor(private userService: UserService) { }
ngOnInit(): void {
this.userService.GetProfilePictureURL(this.username)
.subscribe(url => this.url = url);
}
}

View File

@ -0,0 +1,18 @@
<mat-toolbar>
<mat-toolbar-row>
<mat-icon>chat</mat-icon>Chat
<div class="toolbar-right-side">
<button
*ngIf="this.authService.IsLoggedIn()"
(click)="Logout()"
mat-stroked-button>
Logout
</button>
</div>
</mat-toolbar-row>
</mat-toolbar>

View File

@ -0,0 +1,7 @@
.toolbar-right-side {
width: 100%;
button {
float: right;
}
}

View File

@ -0,0 +1,35 @@
import { Component } from '@angular/core';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { AuthService } from '../../services/auth.service';
import { ToastrService } from 'ngx-toastr';
import { Router } from '@angular/router';
import { NgIf } from '@angular/common';
@Component({
selector: 'app-toolbar',
imports: [MatToolbarModule, MatIconModule, MatButtonModule, NgIf],
templateUrl: './toolbar.component.html',
styleUrl: './toolbar.component.scss'
})
export class ToolbarComponent {
constructor(
public authService: AuthService,
private toastrService: ToastrService,
private router: Router) { }
public Logout(): void {
this.authService.Logout()
.subscribe({
next: result => {
if (result) {
this.router.navigateByUrl('auth/login');
this.toastrService.info("Successfully logged out", "Logout");
} else {
this.toastrService.error("Logout failed", "Error");
}
}
});
}
}

View File

@ -0,0 +1,9 @@
import { Timestamp } from "rxjs"
export class Message {
public id!: number
public sender!: string
public channel!: number
public time!: Date
public content!: string
}

6
src/app/models/user.ts Normal file
View File

@ -0,0 +1,6 @@
export class User {
public username!: string;
public status!: string;
public picture!: string;
public bio!: string;
}

View File

@ -2,7 +2,7 @@ import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { catchError, map, Observable, of } from 'rxjs'; import { catchError, map, Observable, of } from 'rxjs';
import { LoginResponse, RegisterResponse } from './responses/auth'; import { LoginResponse, RegisterResponse } from './responses/auth';
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } from '@angular/router';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -71,5 +71,10 @@ export const IsLoggedInCanActivate: CanActivateFn = (
route: ActivatedRouteSnapshot, route: ActivatedRouteSnapshot,
state: RouterStateSnapshot state: RouterStateSnapshot
) => { ) => {
return inject(AuthService).IsLoggedIn(); if (inject(AuthService).IsLoggedIn()) {
return true;
} else {
inject(Router).navigateByUrl("auth/login");
return false;
}
} }

View File

@ -0,0 +1,56 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Channel } from '../models/channel';
import { Message } from '../models/message';
@Injectable({
providedIn: 'root'
})
export class ChatService {
constructor() { }
// TODO: implement
public ListChannels(): Observable<Channel[]> {
return new Observable<Channel[]>(subscriber => {
subscriber.next([
{
id: 0,
name: 'default',
description: 'this is the default channel'
},
{
id: 1,
name: 'gaming',
description: 'this is another channel'
},
]);
subscriber.complete();
});
}
// TODO: implement
// TODO: refactor this so it first returns the n last messages,
// then listens for incoming messages and forwards them as they come
public GetMessages(channelID: number): Observable<Message[]> {
return new Observable<Message[]>(subscriber => {
subscriber.next([
{
id: 1,
sender: 'Test User 1',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 2,
sender: 'Test User 2',
channel: 2,
time: new Date(),
content: 'this is my second message'
}
]);
subscriber.complete();
});
}
}

View File

@ -1,10 +1,7 @@
export class LoginResponse { import { APIResponse } from "./basic";
public message?: string;
public error?: string; export class LoginResponse extends APIResponse {
public token?: string; public session?: string;
} }
export class RegisterResponse { export class RegisterResponse extends APIResponse { }
public message?: string;
public error?: string;
}

View File

@ -0,0 +1,4 @@
export class APIResponse {
public message?: string;
public error?: string;
}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { User } from '../models/user';
@Injectable({
providedIn: 'root'
})
export class UserService {
constructor() { }
// TODO: implement
public GetProfilePictureURL(username: string): Observable<string> {
return new Observable<string>(subscriber => {
subscriber.next("https://i.pinimg.com/736x/00/70/16/00701602b0eac0390b3107b9e2a665e0.jpg");
subscriber.complete();
});
}
// TODO: implement
public GetUser(username: string): Observable<User> {
throw new Error('Not implemented');
}
}