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>
<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 { RouterOutlet } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'app-root',
imports: [RouterOutlet, MatToolbarModule, MatIconModule],
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent { }
export class AppComponent {
}

View File

@ -13,8 +13,9 @@ export const appConfig: ApplicationConfig = {
provideHttpClient(),
provideAnimations(),
provideToastr({
timeOut: 3000,
timeOut: 1000,
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),
canActivate: [IsLoggedInCanActivate]
},
{ path: '', redirectTo: 'auth', pathMatch: 'full' },
{ path: '', redirectTo: 'chat', 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 { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { ToolbarComponent } from "../common/toolbar/toolbar.component";
@NgModule({
declarations: [
@ -24,7 +25,8 @@ import { MatButtonModule } from '@angular/material/button';
MatFormFieldModule,
MatInputModule,
MatButtonModule,
ReactiveFormsModule
]
ReactiveFormsModule,
ToolbarComponent
]
})
export class AuthModule { }

View File

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

View File

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

View File

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

View File

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

View File

@ -6,18 +6,25 @@ import { ChatComponent } from './chat/chat.component';
import { ChannelListComponent } from './chat/channel-list/channel-list.component';
import { FeedComponent } from './chat/feed/feed.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({
declarations: [
ChatComponent,
ChannelListComponent,
FeedComponent,
ChannelEntryComponent
ChannelEntryComponent,
MessageComponent,
ProfilePictureComponent
],
imports: [
CommonModule,
ChatRoutingModule
ChatRoutingModule,
ToolbarComponent,
MatBadgeModule
]
})
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 {
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;
}

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

View File

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

View File

@ -8,7 +8,9 @@ import { Channel } from '../../models/channel';
styleUrl: './chat.component.scss'
})
export class ChatComponent {
public selectedChannel?: Channel;
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({
selector: 'app-feed',
@ -6,6 +10,18 @@ import { Component } from '@angular/core';
templateUrl: './feed.component.html',
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 { catchError, map, Observable, of } from 'rxjs';
import { LoginResponse, RegisterResponse } from './responses/auth';
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from '@angular/router';
import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } from '@angular/router';
@Injectable({
providedIn: 'root'
@ -71,5 +71,10 @@ export const IsLoggedInCanActivate: CanActivateFn = (
route: ActivatedRouteSnapshot,
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 {
public message?: string;
public error?: string;
public token?: string;
import { APIResponse } from "./basic";
export class LoginResponse extends APIResponse {
public session?: string;
}
export class RegisterResponse {
public message?: string;
public error?: string;
}
export class RegisterResponse extends APIResponse { }

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