Compare commits
2 Commits
6996ecf48c
...
d61f2ed938
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d61f2ed938 | ||
|
|
73132fae13 |
|
|
@ -1,5 +1,5 @@
|
|||
<ng-container>
|
||||
<app-header [current_page]="'chat'" height="460px" background_image="/public/img/backgrounds/staff.png"
|
||||
<app-header [current_page]="'chat'" height="200px" background_image="/public/img/backgrounds/staff.png"
|
||||
[overlay_gradient]="0.5">
|
||||
<div class="title" header-content>
|
||||
<h1>Chat</h1>
|
||||
|
|
@ -7,18 +7,25 @@
|
|||
</app-header>
|
||||
|
||||
<main>
|
||||
<section class="darkmodeSection">
|
||||
@for (message of messages(); track message.timestamp) {
|
||||
<p>
|
||||
<span class="date-time">[{{ message.timestamp | date: 'mediumTime' }}] </span>
|
||||
@if (message.notBlocked) {
|
||||
<span [style.color]=getServerColor(message.server)>[{{ message.server }}] </span>
|
||||
} @else {
|
||||
<span class="blocked">[{{ message.server }}] </span>
|
||||
}
|
||||
<mini-message [node]="message.messageJson"></mini-message>
|
||||
</p>
|
||||
}
|
||||
<app-full-size [hideFooter]="true">
|
||||
<section class="darkmodeSection full-height">
|
||||
<div class="page-layout">
|
||||
<div class="server-list">
|
||||
<app-server-list [servers]="servers()" [selectedServer]="selectedServer()"
|
||||
(selectedServerChange)="onSelectedServerChange($event)">
|
||||
|
||||
</app-server-list>
|
||||
</div>
|
||||
<div class="chat">
|
||||
<div class="chat-info">
|
||||
<app-chat-info [selectedServer]="selectedServer()"></app-chat-info>
|
||||
</div>
|
||||
<div class="chat-box">
|
||||
<app-chat-box [messages]="messages()"></app-chat-box>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</app-full-size>
|
||||
</main>
|
||||
</ng-container>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,32 @@
|
|||
.blocked {
|
||||
color: red;
|
||||
.page-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.server-list {
|
||||
flex: 0 0 auto;
|
||||
width: max-content;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.date-time {
|
||||
color: gray;
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
.chat-info {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 5px 2px 5px;
|
||||
}
|
||||
|
||||
.chat-box {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0 5px 0 5px;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,20 @@ import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
|||
import {Subscription} from 'rxjs';
|
||||
import {ChatService} from '@pages/altitude/chat/service/chat.service';
|
||||
import {HeaderComponent} from '@header/header.component';
|
||||
import {MiniMessageComponent} from '@pages/altitude/chat/mini-message/mini-message.component';
|
||||
import {MiniMessageInteractionService} from '@pages/altitude/chat/mini-message/mini-message-interaction.service';
|
||||
import {DatePipe} from '@angular/common';
|
||||
import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
|
||||
import {ChatBoxComponent} from '@pages/altitude/chat/components/chat/chat-box.component';
|
||||
import {ServerListComponent} from '@pages/altitude/chat/components/server-list/server-list.component';
|
||||
import {ChatInfoComponent} from '@pages/altitude/chat/components/chat-info/chat-info.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat',
|
||||
imports: [
|
||||
HeaderComponent,
|
||||
MiniMessageComponent,
|
||||
DatePipe
|
||||
FullSizeComponent,
|
||||
ChatBoxComponent,
|
||||
ServerListComponent,
|
||||
ChatInfoComponent
|
||||
],
|
||||
templateUrl: './chat.component.html',
|
||||
styleUrl: './chat.component.scss',
|
||||
|
|
@ -21,6 +25,8 @@ export class ChatComponent implements OnInit, OnDestroy {
|
|||
private sub?: Subscription;
|
||||
private readonly chatService: ChatService = inject(ChatService)
|
||||
protected readonly messages = this.chatService.messages;
|
||||
protected readonly servers = this.chatService.servers;
|
||||
protected readonly selectedServer = this.chatService.selectedServer;
|
||||
|
||||
constructor(private interaction: MiniMessageInteractionService) {
|
||||
interaction.clicks$.subscribe(({action, value}) => {
|
||||
|
|
@ -30,6 +36,10 @@ export class ChatComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
public onSelectedServerChange(server: string) {
|
||||
this.chatService.selectServer(server);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.chatService.connect();
|
||||
|
||||
|
|
@ -39,15 +49,4 @@ export class ChatComponent implements OnInit, OnDestroy {
|
|||
this.sub?.unsubscribe();
|
||||
this.chatService.disconnect(); // triggers onCompletion server-side, cleans up the emitter
|
||||
}
|
||||
|
||||
protected getServerColor(server: string) {
|
||||
if (server === 'bayou') {
|
||||
return "#007FFF";
|
||||
} else if (server === 'lobby') {
|
||||
return "white";
|
||||
} else if (server === 'creative') {
|
||||
return "#FFA500";
|
||||
}
|
||||
return "white";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
<div class="full-width"
|
||||
[style.background-color]="getServerColor(selectedServer())">
|
||||
<span class="server" [style.color]="getServerTextColor(selectedServer())">
|
||||
{{ selectedServer() }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
.server {
|
||||
padding-left: 10px;
|
||||
font-size: 25px;
|
||||
font-family: minecraft-text, 'opensans-bold', sans-serif;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import {Component, input} from '@angular/core';
|
||||
import {getServerColor, getServerTextColor} from '@pages/altitude/chat/util/server-color';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-info',
|
||||
imports: [],
|
||||
templateUrl: './chat-info.component.html',
|
||||
styleUrl: './chat-info.component.scss'
|
||||
})
|
||||
export class ChatInfoComponent {
|
||||
public readonly selectedServer = input.required<string>();
|
||||
protected readonly getServerColor = getServerColor;
|
||||
protected readonly getServerTextColor = getServerTextColor;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
@for (message of messages(); track message.timestamp) {
|
||||
<p>
|
||||
<span class="date-time" [class.blocked]="!message.notBlocked">
|
||||
[{{ message.timestamp * 1000 | date: 'mediumTime' }}]
|
||||
</span>
|
||||
<mini-message [node]="message.messageJson"></mini-message>
|
||||
</p>
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.blocked {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.date-time {
|
||||
color: gray;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import {Component, input} from '@angular/core';
|
||||
import {DatePipe} from '@angular/common';
|
||||
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
|
||||
import {MiniMessageComponent} from '@pages/altitude/chat/components/mini-message/mini-message.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-box',
|
||||
imports: [
|
||||
MiniMessageComponent,
|
||||
DatePipe
|
||||
],
|
||||
templateUrl: './chat-box.component.html',
|
||||
styleUrl: './chat-box.component.scss'
|
||||
})
|
||||
export class ChatBoxComponent {
|
||||
readonly messages = input.required<ChatMessage[]>();
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
@if (node) {
|
||||
<ng-container>
|
||||
<a
|
||||
*ngIf="isUrlLink; else plain"
|
||||
[ngStyle]="ngStyle"
|
||||
[mmObfuscated]="style.obfuscated"
|
||||
[attr.title]="hoverTitle"
|
||||
[attr.href]="node.clickEvent?.value"
|
||||
[attr.data-insertion]="node.insertion || null"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mm-node mm-link"
|
||||
>{{ text }}</a>
|
||||
|
||||
<ng-template #plain>
|
||||
<span
|
||||
[ngStyle]="ngStyle"
|
||||
[mmObfuscated]="style.obfuscated"
|
||||
[attr.title]="hoverTitle"
|
||||
[attr.data-insertion]="node.insertion || null"
|
||||
[class.mm-clickable]="hasNonUrlClick"
|
||||
class="mm-node"
|
||||
(click)="onClick()"
|
||||
>{{ text }}</span
|
||||
>
|
||||
</ng-template>
|
||||
@for (child of node.extra; track child) {
|
||||
<mini-message [node]="child" [parentStyle]="style"></mini-message>
|
||||
}
|
||||
</ng-container>
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
.mm-node {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.mm-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mm-link {
|
||||
text-decoration: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import {CommonModule} from '@angular/common';
|
||||
import {Component, Input, Optional} from '@angular/core';
|
||||
import {MiniMessageComponent as MiniMessageNode, ResolvedStyle} from './mini-message.types';
|
||||
import {hoverEventToTitle, mergeStyle, ownText, styleToNgStyle} from './mini-message.util';
|
||||
import {ObfuscatedDirective} from './obfuscated.directive';
|
||||
import {MiniMessageInteractionService} from './mini-message-interaction.service';
|
||||
import {MiniMessageComponent as MiniMessageNode, ResolvedStyle} from '../../mini-message/mini-message.types';
|
||||
import {hoverEventToTitle, mergeStyle, ownText, styleToNgStyle} from '../../mini-message/mini-message.util';
|
||||
import {ObfuscatedDirective} from '../../mini-message/obfuscated.directive';
|
||||
import {MiniMessageInteractionService} from '../../mini-message/mini-message-interaction.service';
|
||||
|
||||
/**
|
||||
* Renders a Paper/Adventure component-JSON tree as styled HTML.
|
||||
|
|
@ -20,51 +20,8 @@ import {MiniMessageInteractionService} from './mini-message-interaction.service'
|
|||
selector: 'mini-message',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ObfuscatedDirective],
|
||||
template: `
|
||||
<ng-container *ngIf="node">
|
||||
<a
|
||||
*ngIf="isUrlLink; else plain"
|
||||
[ngStyle]="ngStyle"
|
||||
[mmObfuscated]="style.obfuscated"
|
||||
[attr.title]="hoverTitle"
|
||||
[attr.href]="node.clickEvent?.value"
|
||||
[attr.data-insertion]="node.insertion || null"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mm-node mm-link"
|
||||
>{{ text }}</a
|
||||
>
|
||||
<ng-template #plain>
|
||||
<span
|
||||
[ngStyle]="ngStyle"
|
||||
[mmObfuscated]="style.obfuscated"
|
||||
[attr.title]="hoverTitle"
|
||||
[attr.data-insertion]="node.insertion || null"
|
||||
[class.mm-clickable]="hasNonUrlClick"
|
||||
class="mm-node"
|
||||
(click)="onClick()"
|
||||
>{{ text }}</span
|
||||
>
|
||||
</ng-template>
|
||||
<mini-message *ngFor="let child of node.extra" [node]="child" [parentStyle]="style"></mini-message>
|
||||
</ng-container>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.mm-node {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.mm-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mm-link {
|
||||
text-decoration: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
],
|
||||
templateUrl: './mini-message.component.html',
|
||||
styleUrl: './mini-message.component.scss',
|
||||
})
|
||||
export class MiniMessageComponent {
|
||||
@Input() node!: MiniMessageNode;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<div class="server-list">
|
||||
<p>Servers</p>
|
||||
@for (server of servers(); track server.server) {
|
||||
<div class="server" (click)="selectedServerChange.emit(server.server)">
|
||||
<span class="server-name">{{ server.server }}</span>
|
||||
@if (server.unreadMessages > 0) {
|
||||
<div class="notification">
|
||||
<svg-circle></svg-circle>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
.server-list {
|
||||
height: 100%;
|
||||
background-color: #172133;
|
||||
|
||||
.server {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 2px 2px 2px 10px;
|
||||
border-bottom: 1px solid #222;
|
||||
|
||||
.server-name {
|
||||
color: white
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import {Component, effect, input, output} from '@angular/core';
|
||||
import {ChatServer} from '@pages/altitude/chat/objects/chat-server.object';
|
||||
import {CircleSvgComponent} from '@pages/altitude/chat/svg/circle.svg';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-list',
|
||||
imports: [
|
||||
CircleSvgComponent
|
||||
],
|
||||
templateUrl: './server-list.component.html',
|
||||
styleUrl: './server-list.component.scss'
|
||||
})
|
||||
export class ServerListComponent {
|
||||
public readonly servers = input.required<ChatServer[]>();
|
||||
public readonly selectedServer = input.required<string>();
|
||||
public readonly selectedServerChange = output<string>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const chatServers = this.servers();
|
||||
if (this.selectedServer() === '' && chatServers.length > 0) {
|
||||
this.selectedServerChange.emit(chatServers[0].server);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export interface ChatServer {
|
||||
server: string;
|
||||
unreadMessages: number;
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import {inject, Injectable, OnDestroy, signal} from '@angular/core';
|
||||
import {computed, inject, Injectable, OnDestroy, signal} from '@angular/core';
|
||||
import {AuthService} from '@services/auth.service';
|
||||
import {EventSourcePolyfill} from 'event-source-polyfill';
|
||||
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
|
||||
import {MiniMessageComponent} from '@pages/altitude/chat/mini-message/mini-message.types';
|
||||
import {RawChatMessage} from '@pages/altitude/chat/objects/raw-chat-message.object';
|
||||
import {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util';
|
||||
import {ChatServer} from '@pages/altitude/chat/objects/chat-server.object';
|
||||
|
||||
export interface ChatEvent {
|
||||
type: string;
|
||||
|
|
@ -20,7 +20,11 @@ export class ChatService implements OnDestroy {
|
|||
private eventSource?: EventSourcePolyfill;
|
||||
private readonly authService: AuthService = inject(AuthService)
|
||||
private readonly _messages = signal<ChatMessage[]>([])
|
||||
public readonly messages = this._messages.asReadonly()
|
||||
public readonly messages = computed(() => this._messages().filter((message) => message.server === this._selectedServer()));
|
||||
private readonly _servers = signal<ChatServer[]>([])
|
||||
public readonly servers = computed(() => this._servers().sort((a, b) => a.server.localeCompare(b.server)));
|
||||
private readonly _selectedServer = signal<string>('');
|
||||
public readonly selectedServer = this._selectedServer.asReadonly()
|
||||
|
||||
connect() {
|
||||
if (this.eventSource) {
|
||||
|
|
@ -43,26 +47,45 @@ export class ChatService implements OnDestroy {
|
|||
this.eventSource = source;
|
||||
|
||||
this.on(source, 'connected', (event) => {
|
||||
this.processChatMessages(event);
|
||||
});
|
||||
|
||||
this.on(source, 'chat', (event) => {
|
||||
const chatMessages = this.processChatMessages(event);
|
||||
chatMessages.forEach((message) => {
|
||||
const find = this.servers().find(server => message.server === server.server);
|
||||
if (find) {
|
||||
find.unreadMessages++;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
source.onerror = (err) => {
|
||||
console.error('SSE error, polyfill will auto-reconnect:', err);
|
||||
};
|
||||
}
|
||||
|
||||
private processChatMessages(event: SsePayloadEvent): ChatMessage[] {
|
||||
const raw = JSON.parse(event.data) as RawChatMessage[];
|
||||
const messages: ChatMessage[] = raw.map((m) => ({
|
||||
...m,
|
||||
messageJson: normalizeComponent(JSON.parse(m.messageJson)),
|
||||
}));
|
||||
this._messages.update((old) => [...old, ...messages]);
|
||||
});
|
||||
|
||||
this.on(source, 'chat', (event) => {
|
||||
const raw = JSON.parse(event.data) as RawChatMessage[];
|
||||
const messages: ChatMessage[] = raw.map((m) => ({
|
||||
...m,
|
||||
messageJson: JSON.parse(m.messageJson) as MiniMessageComponent,
|
||||
}));
|
||||
this._messages.update((old) => [...old, ...messages]);
|
||||
});
|
||||
//TODO [Stijn] [2026-07-19]: Handle servers by getting them from backend
|
||||
messages.map((message) => message.server).forEach((server) => {
|
||||
const found = this._servers().find(existing => existing.server === server);
|
||||
if (!found) {
|
||||
this._servers.update((old) => [...old, {server: server, unreadMessages: 0}]);
|
||||
}
|
||||
})
|
||||
|
||||
source.onerror = (err) => {
|
||||
console.error('SSE error, polyfill will auto-reconnect:', err);
|
||||
};
|
||||
return messages;
|
||||
}
|
||||
|
||||
public selectServer(server: string) {
|
||||
this._selectedServer.set(server);
|
||||
}
|
||||
|
||||
// single, deliberate escape hatch from the broken DOM/polyfill type overlap —
|
||||
|
|
|
|||
19
frontend/src/app/pages/altitude/chat/svg/circle.svg.ts
Normal file
19
frontend/src/app/pages/altitude/chat/svg/circle.svg.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import {Component} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'svg-circle',
|
||||
imports: [],
|
||||
template: `
|
||||
<svg
|
||||
width="10px"
|
||||
height="10px" viewBox="0 0 6 6" id="meteor-icon-kit__solid-circle-xxxs" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M3 6C1.3431 6 0 4.6569 0 3C0 1.3431 1.3431 0 3 0C4.6569 0 6 1.3431 6 3C6 4.6569 4.6569 6 3 6z"
|
||||
fill="currentColor"/>
|
||||
</svg>
|
||||
`
|
||||
})
|
||||
export class CircleSvgComponent {
|
||||
|
||||
}
|
||||
21
frontend/src/app/pages/altitude/chat/util/server-color.ts
Normal file
21
frontend/src/app/pages/altitude/chat/util/server-color.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export function getServerColor(server: string) {
|
||||
if (server === 'bayou') {
|
||||
return "#0290e8";
|
||||
} else if (server === 'lobby') {
|
||||
return "white";
|
||||
} else if (server === 'creative') {
|
||||
return "#FFA500";
|
||||
}
|
||||
return "white";
|
||||
}
|
||||
|
||||
export function getServerTextColor(server: string) {
|
||||
if (server === 'bayou') {
|
||||
return "white";
|
||||
} else if (server === 'lobby') {
|
||||
return "black";
|
||||
} else if (server === 'creative') {
|
||||
return "black";
|
||||
}
|
||||
return "white";
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
import {HttpInterceptorFn} from '@angular/common/http';
|
||||
import {inject} from '@angular/core';
|
||||
import {AuthService} from '@services/auth.service';
|
||||
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const authService = inject(AuthService);
|
||||
const jwt = authService.getJwt();
|
||||
const jwt = localStorage.getItem('jwt')
|
||||
|
||||
if (jwt) {
|
||||
const authReq = req.clone({
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user