diff --git a/pom.xml b/pom.xml index 2bb89de..6de0e12 100644 --- a/pom.xml +++ b/pom.xml @@ -12,6 +12,7 @@ 21 21 + 21 UTF-8 @@ -26,6 +27,11 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-websocket + + org.projectlombok lombok diff --git a/src/main/angular/angular.json b/src/main/angular/angular.json index 5f9d590..1a8aac4 100644 --- a/src/main/angular/angular.json +++ b/src/main/angular/angular.json @@ -72,6 +72,12 @@ "maximumError": "4kB" } ], + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], "outputHashing": "all" }, "development": { diff --git a/src/main/angular/package-lock.json b/src/main/angular/package-lock.json index 487ac50..d568a21 100644 --- a/src/main/angular/package-lock.json +++ b/src/main/angular/package-lock.json @@ -16,6 +16,8 @@ "@angular/platform-browser": "^18.2.0", "@angular/platform-browser-dynamic": "^18.2.0", "@angular/router": "^18.2.0", + "@stomp/ng2-stompjs": "^8.0.0", + "ngx-cookie-service": "^18.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.14.10" @@ -4283,6 +4285,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@stomp/ng2-stompjs": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@stomp/ng2-stompjs/-/ng2-stompjs-8.0.0.tgz", + "integrity": "sha512-1TTwdK7aUaRLfewG2FOqd3ZeIyIchKwOBzJytvUckjrBbpdeAXLXY0mCBFczoY16fSsxiAnZfJffFicStD+Dug==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "Apache-2.0", + "dependencies": { + "@stomp/rx-stomp": "^1.0.0 >=1.0.1", + "tslib": "^1.9.0" + } + }, + "node_modules/@stomp/ng2-stompjs/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@stomp/rx-stomp": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@stomp/rx-stomp/-/rx-stomp-1.2.0.tgz", + "integrity": "sha512-QLzPe3q0EwLB+cVWdUFEO4z5tyR+kPnXJANKN2UvB7Spz/oViHF959cydmXdQWaK7NHp86VO54TgFfXbHVnSLg==", + "license": "Apache-2.0", + "dependencies": { + "@stomp/stompjs": "^6.0.0 >=6.1.1", + "angular2-uuid": "^1.1.1" + } + }, + "node_modules/@stomp/stompjs": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-6.1.2.tgz", + "integrity": "sha512-FHDTrIFM5Ospi4L3Xhj6v2+NzCVAeNDcBe95YjUWhWiRMrBF6uN3I7AUOlRgT6jU/2WQvvYK8ZaIxFfxFp+uHQ==", + "license": "Apache-2.0" + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -4943,6 +4978,12 @@ "ajv": "^8.8.2" } }, + "node_modules/angular2-uuid": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/angular2-uuid/-/angular2-uuid-1.1.1.tgz", + "integrity": "sha512-6AXPyii9q8KBFGagybLNVmdGJLPcVZAhmv3odNGSJIA18LuJ3xOe6uN9GvjlQsGfdmYeuxlsGnFEUu7gPhkc+g==", + "license": "MIT" + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -10100,6 +10141,19 @@ "dev": true, "license": "MIT" }, + "node_modules/ngx-cookie-service": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-18.0.0.tgz", + "integrity": "sha512-hkkUckzZTXXWtFgvVkT2hg6mwYMLXioXDZWBsVCOy9gYkADjsj0N5VViO7eo2izQ0VcMPd/Etog1trf/T4oZMQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "peerDependencies": { + "@angular/common": "^18.0.0-rc.0", + "@angular/core": "^18.0.0-rc.0" + } + }, "node_modules/nice-napi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", diff --git a/src/main/angular/package.json b/src/main/angular/package.json index 6f11947..f2fb28f 100644 --- a/src/main/angular/package.json +++ b/src/main/angular/package.json @@ -20,7 +20,9 @@ "@angular/router": "^18.2.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", - "zone.js": "~0.14.10" + "zone.js": "~0.14.10", + "@stomp/ng2-stompjs": "^8.0.0", + "ngx-cookie-service": "^18.0.0" }, "devDependencies": { "@angular-devkit/build-angular": "^18.2.3", diff --git a/src/main/angular/src/app/api/Session/AbstractSession.ts b/src/main/angular/src/app/api/Session/AbstractSession.ts new file mode 100644 index 0000000..142b8e2 --- /dev/null +++ b/src/main/angular/src/app/api/Session/AbstractSession.ts @@ -0,0 +1,20 @@ +import {UserPublic} from "../User/UserPublic"; + +export abstract class AbstractSession { + + protected constructor( + readonly type: string, + readonly typeDisplayName: string, + readonly uuid: string, + readonly owner: UserPublic, + readonly created: Date, + readonly title: string, + readonly password: string, + readonly users: UserPublic[], + readonly initial: boolean, + ) { + // - + } + +} + diff --git a/src/main/angular/src/app/api/Session/AbstractSessionService.ts b/src/main/angular/src/app/api/Session/AbstractSessionService.ts new file mode 100644 index 0000000..d51e116 --- /dev/null +++ b/src/main/angular/src/app/api/Session/AbstractSessionService.ts @@ -0,0 +1,70 @@ +import {ApiService} from "../common/api.service"; +import {FromJson, Next} from "../common/types"; +import {AbstractSession} from "./AbstractSession"; +import {Router} from "@angular/router"; +import {UserService} from "../User/user.service"; +import {validateBoolean} from "../common/validators"; + +export abstract class AbstractSessionService { + + protected constructor( + protected readonly api: ApiService, + protected readonly router: Router, + protected readonly userService: UserService, + readonly apiPath: any[], + readonly routerPath: any[], + readonly fromJson: FromJson, + ) { + // - + } + + canAccess(uuid: string, next: Next): void { + this.api.postSingle([...this.apiPath, 'canAccess'], uuid, validateBoolean, next); + } + + get(uuid: string, next: Next): void { + this.api.postSingle([...this.apiPath, 'get'], uuid, this.fromJson, next); + } + + create(next: Next): void { + this.api.getSingle([...this.apiPath, 'create'], this.fromJson, session => { + next(session); + this.userService.refresh(); + }); + } + + changeTitle(session: SESSION, title: string, next?: Next) { + const data = { + uuid: session.uuid, + title: title, + }; + this.api.postSingle([...this.apiPath, 'changeTitle'], data, this.fromJson, next); + } + + changePassword(session: SESSION, password: string, next?: Next) { + const data = { + uuid: session.uuid, + password: password, + }; + this.api.postSingle([...this.apiPath, 'changePassword'], data, this.fromJson, next); + } + + join(uuid: string, password: string, next: Next): void { + const data = { + uuid: uuid, + password: password, + }; + this.api.postSingle([...this.apiPath, 'join'], data, this.fromJson, next); + } + + leave(session: SESSION, next: Next): void { + this.api.postNone([...this.apiPath, 'leave'], session, next); + } + + goto(session: SESSION) { + const url = '/' + this.routerPath.join('/'); + this.router.navigate([url, {uuid: session.uuid}]); + } + +} + diff --git a/src/main/angular/src/app/api/Session/sessionFromJsonOrNull.ts b/src/main/angular/src/app/api/Session/sessionFromJsonOrNull.ts new file mode 100644 index 0000000..96960be --- /dev/null +++ b/src/main/angular/src/app/api/Session/sessionFromJsonOrNull.ts @@ -0,0 +1,14 @@ +import {AbstractSession} from "./AbstractSession"; +import {validateString} from "../common/validators"; +import {NumbersSession} from "../tools/Numbers/Session/NumbersSession"; + +export function sessionFromJsonOrNull(json: any): AbstractSession | null { + const type = validateString(json['type']); + switch (type) { + case 'Numbers': + return NumbersSession.fromJson(json); + default: + console.error("Not implemented: AbstractSession.type=" + type); + return null; + } +} diff --git a/src/main/angular/src/app/api/User/UserCommon.ts b/src/main/angular/src/app/api/User/UserCommon.ts new file mode 100644 index 0000000..c21bf41 --- /dev/null +++ b/src/main/angular/src/app/api/User/UserCommon.ts @@ -0,0 +1,24 @@ +import {validateListIgnoreNullItems, validateString} from "../common/validators"; +import {AbstractSession} from "../Session/AbstractSession"; +import {sessionFromJsonOrNull} from "../Session/sessionFromJsonOrNull"; +import {UserPublic} from "./UserPublic"; + +export class UserCommon extends UserPublic { + + constructor( + publicUuid: string, + name: string, + readonly commonSessions: AbstractSession[], + ) { + super(publicUuid, name); + } + + static override fromJson(json: any): UserCommon { + return new UserCommon( + validateString(json['publicUuid']), + validateString(json['name']), + validateListIgnoreNullItems(json['commonSessions'], sessionFromJsonOrNull), + ); + } + +} diff --git a/src/main/angular/src/app/api/User/UserPrivate.ts b/src/main/angular/src/app/api/User/UserPrivate.ts new file mode 100644 index 0000000..4b51507 --- /dev/null +++ b/src/main/angular/src/app/api/User/UserPrivate.ts @@ -0,0 +1,35 @@ +import {validateDate, validateListIgnoreNullItems, validateString} from "../common/validators"; +import {AbstractSession} from "../Session/AbstractSession"; + +import {sessionFromJsonOrNull} from "../Session/sessionFromJsonOrNull"; + +export class UserPrivate { + + constructor( + readonly privateUuid: string, + readonly publicUuid: string, + readonly created: Date, + readonly sessions: AbstractSession[], + readonly name: string, + ) { + // - + } + + static fromJson(json: any): UserPrivate { + return new UserPrivate( + validateString(json['privateUuid']), + validateString(json['publicUuid']), + validateDate(json['created']), + validateListIgnoreNullItems(json['sessions'], sessionFromJsonOrNull), + validateString(json['name']), + ); + } + + static fromJsonOrNull(json: any): UserPrivate | null { + if (json === null || json === undefined) { + return null; + } + return UserPrivate.fromJson(json); + } + +} diff --git a/src/main/angular/src/app/api/User/UserPublic.ts b/src/main/angular/src/app/api/User/UserPublic.ts new file mode 100644 index 0000000..d186e3e --- /dev/null +++ b/src/main/angular/src/app/api/User/UserPublic.ts @@ -0,0 +1,19 @@ +import {validateString} from "../common/validators"; + +export class UserPublic { + + constructor( + readonly publicUuid: string, + readonly name: string, + ) { + // - + } + + static fromJson(json: any): UserPublic { + return new UserPublic( + validateString(json['publicUuid']), + validateString(json['name']), + ); + } + +} diff --git a/src/main/angular/src/app/api/User/user.service.ts b/src/main/angular/src/app/api/User/user.service.ts new file mode 100644 index 0000000..26cc05f --- /dev/null +++ b/src/main/angular/src/app/api/User/user.service.ts @@ -0,0 +1,60 @@ +import {Injectable} from '@angular/core'; +import {ApiService} from "../common/api.service"; +import {UserPrivate} from "./UserPrivate"; +import {Next} from "../common/types"; +import {UserCommon} from "./UserCommon"; +import {UserPublic} from "./UserPublic"; +import {Router} from "@angular/router"; +import {BehaviorSubject, Subscription} from "rxjs"; +import {AbstractSession} from "../Session/AbstractSession"; + +@Injectable({ + providedIn: 'root' +}) +export class UserService { + + private readonly subject: BehaviorSubject = new BehaviorSubject(null); + + get user(): UserPrivate | null { + return this.subject.value; + } + + constructor( + protected readonly router: Router, + protected readonly api: ApiService, + ) { + this.refresh(); + } + + getCommonByUuid(uuid: string, next: Next): void { + this.api.postSingle(['User', 'getCommonByUuid'], uuid, UserCommon.fromJson, next); + } + + delete(next?: Next) { + this.api.getNone(['User', 'delete'], _ => { + this.refresh(); + next && next(); + }); + } + + changeName(name: string, next?: Next) { + this.api.postSingle(['User', 'changeName'], name, UserPrivate.fromJson, next); + } + + goto(user: UserPublic) { + this.router.navigate(['/User', {uuid: user.publicUuid}]); + } + + refresh() { + this.api.getSingle(['User', 'whoAmI'], UserPrivate.fromJsonOrNull, user => this.subject.next(user)); + } + + subscribe(next: Next): Subscription { + return this.subject.subscribe(next); + } + + owns(session: AbstractSession): boolean { + return this.user?.publicUuid === session.owner.publicUuid; + } + +} diff --git a/src/main/angular/src/app/api/common/CrudDirection.ts b/src/main/angular/src/app/api/common/CrudDirection.ts new file mode 100644 index 0000000..712430a --- /dev/null +++ b/src/main/angular/src/app/api/common/CrudDirection.ts @@ -0,0 +1,4 @@ +export enum CrudDirection { + ASC = 'ASC', + DESC = 'DESC', +} diff --git a/src/main/angular/src/app/api/common/Order.ts b/src/main/angular/src/app/api/common/Order.ts new file mode 100644 index 0000000..452cc3a --- /dev/null +++ b/src/main/angular/src/app/api/common/Order.ts @@ -0,0 +1,13 @@ +import {CrudDirection} from "./CrudDirection"; + +export class Order { + + constructor( + readonly property: string, + readonly compare: (a: T, b: T) => number, + public direction: CrudDirection, + ) { + // - + } + +} diff --git a/src/main/angular/src/app/api/common/Page.ts b/src/main/angular/src/app/api/common/Page.ts new file mode 100644 index 0000000..a90508f --- /dev/null +++ b/src/main/angular/src/app/api/common/Page.ts @@ -0,0 +1,28 @@ +import {validateList, validateNumber} from "./validators"; +import {FromJson} from "./types"; + +export class Page { + + static readonly EMPTY: Page = new Page(0, 0, 0, 0, []); + + constructor( + readonly size: number, + readonly number: number, + readonly totalPages: number, + readonly totalElements: number, + readonly content: T[], + ) { + // - + } + + static fromJson(fromJson: FromJson): FromJson> { + return (json: any) => new Page( + validateNumber(json.page.size), + validateNumber(json.page.number), + validateNumber(json.page.totalPages), + validateNumber(json.page.totalElements), + validateList(json.content, fromJson), + ); + } + +} diff --git a/src/main/angular/src/app/api/common/Sort.ts b/src/main/angular/src/app/api/common/Sort.ts new file mode 100644 index 0000000..aeecbe1 --- /dev/null +++ b/src/main/angular/src/app/api/common/Sort.ts @@ -0,0 +1,65 @@ +import {CrudDirection} from "./CrudDirection"; + +import {Order} from "./Order"; + +export class Sort { + + private list: Order[] = []; + + toggle(key: string, compare: (a: T, b: T) => number) { + const index: number = this.indexOf(key); + if (index >= 0) { + const existing: Order = this.list[index]; + if (existing.direction === CrudDirection.ASC) { + existing.direction = CrudDirection.DESC; + } else { + this.list.splice(index, 1); + } + } else { + this.list.push(new Order(key, compare, CrudDirection.ASC)); + } + } + + compare(a: T, b: T): number { + for (const order of this.list) { + const result = order.compare(a, b); + if (result != 0) { + return order.direction === CrudDirection.DESC ? -result : result; + } + } + return 0; + } + + isEmpty(): boolean { + return this.list.length === 0; + } + + indexOf(key: string): number { + return this.list.findIndex(o => o.property === key); + } + + isActive(key: string): boolean { + return this.indexOf(key) >= 0; + } + + isAscending(key: string): boolean { + return this.isDirection(key, CrudDirection.ASC); + } + + isDescending(key: string): boolean { + return this.isDirection(key, CrudDirection.DESC); + } + + isDirection(key: string, direction: CrudDirection): boolean { + const index: number = this.indexOf(key); + if (index < 0) { + return false; + } + return this.list[index].direction === direction; + } + + clear(): void { + this.list = []; + } + +} diff --git a/src/main/angular/src/app/api/common/api.service.ts b/src/main/angular/src/app/api/common/api.service.ts new file mode 100644 index 0000000..92e8cb7 --- /dev/null +++ b/src/main/angular/src/app/api/common/api.service.ts @@ -0,0 +1,75 @@ +import {Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {map, Subscription} from "rxjs"; +import {FromJson, getApiUrl, Next} from "./types"; +import {Page} from "./Page"; +import {StompService} from "@stomp/ng2-stompjs"; + +@Injectable({ + providedIn: 'root', +}) +export class ApiService { + + private _websocketError: boolean = false; + + get websocketError(): boolean { + return this._websocketError; + } + + constructor( + private readonly http: HttpClient, + private readonly stompService: StompService, + ) { + stompService.connected$.subscribe(() => this._websocketError = false); + stompService.webSocketErrors$.subscribe(() => this._websocketError = true); + } + + connected(next: Next): Subscription { + return this.stompService.connected$.subscribe(_ => next()); + } + + subscribe(topic: any[], fromJson: FromJson, next: Next): Subscription { + console.info("WEBSOCKET SUBSCRIBE", topic) + return this.stompService + .subscribe(topic.join("/")) + .pipe( + map(message => message.body), + map(b => JSON.parse(b)), + map(j => fromJson(j)), + ) + .subscribe(next); + } + + getNone(path: any[], next?: Next): Subscription { + return this.http.get(getApiUrl('http', path), {withCredentials: true}).subscribe(next); + } + + getString(path: any[], next: Next): Subscription { + return this.http.get(getApiUrl('http', path), {responseType: "text", withCredentials: true}).subscribe(next); + } + + getSingle(path: any[], fromJson: FromJson, next?: Next): Subscription { + return this.http.get(getApiUrl('http', path), {withCredentials: true}).pipe(map(fromJson)).subscribe(next); + } + + getList(path: any[], fromJson: FromJson, next: Next | undefined = undefined): Subscription { + return this.http.get(getApiUrl('http', path), {withCredentials: true}).pipe(map(list => list.map(fromJson))).subscribe(next); + } + + postNone(path: any[], data: any, next?: Next): Subscription { + return this.http.post(getApiUrl('http', path), data, {withCredentials: true}).subscribe(next); + } + + postSingle(path: any[], data: any, fromJson: FromJson, next?: Next): Subscription { + return this.http.post(getApiUrl('http', path), data, {withCredentials: true}).pipe(map(fromJson)).subscribe(next); + } + + postPage(path: any[], data: any, fromJson: FromJson, next: Next> | undefined = undefined): Subscription { + return this.http.post(getApiUrl('http', path), data, {withCredentials: true}).pipe(map(Page.fromJson(fromJson))).subscribe(next); + } + + postList(path: any[], data: any, fromJson: FromJson, next: Next | undefined = undefined): Subscription { + return this.http.post(getApiUrl('http', path), data, {withCredentials: true}).pipe(map(list => list.map(fromJson))).subscribe(next); + } + +} diff --git a/src/main/angular/src/app/api/common/types.ts b/src/main/angular/src/app/api/common/types.ts new file mode 100644 index 0000000..f940ccb --- /dev/null +++ b/src/main/angular/src/app/api/common/types.ts @@ -0,0 +1,9 @@ +import {environment} from "../../../environments/environment"; + +export type FromJson = (json: any) => T; + +export type Next = (item: T) => void; + +export function getApiUrl(protocol: string, path: any[]): string { + return protocol + (environment.secure ? 's' : '') + '://' + environment.host + ':' + environment.port + '/' + environment.base + path.join('/'); +} diff --git a/src/main/angular/src/app/api/common/validators.ts b/src/main/angular/src/app/api/common/validators.ts new file mode 100644 index 0000000..51f7696 --- /dev/null +++ b/src/main/angular/src/app/api/common/validators.ts @@ -0,0 +1,73 @@ +import {FromJson} from "./types"; + +export function validateNumber(json: any): number { + if (!(typeof json === "number")) { + throw new Error("Not a number: " + json + " (" + typeof json + "): " + JSON.stringify(json)); + } + return json; +} + +export function validateNumberOrNull(json: any): number | null { + if (json === null || json === undefined) { + return null; + } + return validateNumber(json); +} + +export function validateBoolean(json: any): boolean { + if (!(typeof json === "boolean")) { + throw new Error("Not a boolean: " + json + " (" + typeof json + "): " + JSON.stringify(json)); + } + return json; +} + +export function validateBooleanOrNull(json: any): boolean | null { + if (json === null || json === undefined) { + return null; + } + return validateBoolean(json); +} + +export function validateString(json: any): string { + if (!(typeof json === "string")) { + throw new Error("Not a string: " + json + " (" + typeof json + "): " + JSON.stringify(json)); + } + return json; +} + +export function validateStringOrNull(json: any): string | null { + if (json === null || json === undefined) { + return null; + } + return validateString(json); +} + +export function validateDate(json: any): Date { + return new Date(Date.parse(validateString(json))); +} + +export function validateDateOrNull(json: any): Date | null { + if (json === null || json === undefined) { + return null; + } + return validateDate(json); +} + +export function validateListIgnoreNullItems(jsonList: any, fromJson: FromJson): T[] { + return jsonList.map(fromJson).filter((item: T | null) => item !== null); +} + +export function validateList(jsonList: any, fromJson: FromJson): T[] { + return jsonList.map(fromJson); +} + +export function validateLocalDateOrNull(json: any): Date | null { + if (json === null || json === undefined) { + return null; + } + return validateLocalDate(json); +} + +export function validateLocalDate(json: any): Date { + return new Date(validateString(json)); +} diff --git a/src/main/angular/src/app/api/common/ws.ts b/src/main/angular/src/app/api/common/ws.ts new file mode 100644 index 0000000..d67bfb9 --- /dev/null +++ b/src/main/angular/src/app/api/common/ws.ts @@ -0,0 +1,17 @@ +import {StompService} from "@stomp/ng2-stompjs"; +import {getApiUrl} from "./types"; + +export function stompServiceFactory() { + const stomp = new StompService({ + url: getApiUrl('ws', ['ws']), + debug: false, + heartbeat_in: 2000, + heartbeat_out: 2000, + reconnect_delay: 2000, + headers: {}, + }); + stomp.connected$.subscribe(_ => console.info("WEBSOCKET CONNECTED")); + stomp.webSocketErrors$.subscribe(_ => console.info("WEBSOCKET DISCONNECTED")); + stomp.activate(); + return stomp; +} diff --git a/src/main/angular/src/app/api/tools/Numbers/Session/NumbersSession.ts b/src/main/angular/src/app/api/tools/Numbers/Session/NumbersSession.ts new file mode 100644 index 0000000..cd56d93 --- /dev/null +++ b/src/main/angular/src/app/api/tools/Numbers/Session/NumbersSession.ts @@ -0,0 +1,40 @@ +import {UserPublic} from "../../../User/UserPublic"; +import {validateBoolean, validateDate, validateList, validateString} from "../../../common/validators"; +import {AbstractSession} from "../../../Session/AbstractSession"; + +export class NumbersSession extends AbstractSession { + + static readonly TYPE = 'Numbers'; + + static readonly TYPE_DISPLAY_NAME = 'Nummern verteilen'; + + constructor( + type: string, + uuid: string, + title: string, + owner: UserPublic, + created: Date, + password: string, + users: UserPublic[], + initial: boolean, + ) { + if (type !== NumbersSession.TYPE) { + throw new Error(); + } + super(NumbersSession.TYPE, NumbersSession.TYPE_DISPLAY_NAME, uuid, owner, created, title, password, users, initial); + } + + static fromJson(json: any): NumbersSession { + return new NumbersSession( + validateString(json['type']), + validateString(json['uuid']), + validateString(json['title']), + UserPublic.fromJson(json['owner']), + validateDate(json['created']), + validateString(json['password']), + validateList(json['users'], UserPublic.fromJson), + validateBoolean(json['initial']), + ); + } + +} diff --git a/src/main/angular/src/app/api/tools/Numbers/Session/numbers-session.service.ts b/src/main/angular/src/app/api/tools/Numbers/Session/numbers-session.service.ts new file mode 100644 index 0000000..923311f --- /dev/null +++ b/src/main/angular/src/app/api/tools/Numbers/Session/numbers-session.service.ts @@ -0,0 +1,21 @@ +import {Injectable} from '@angular/core'; +import {AbstractSessionService} from "../../../Session/AbstractSessionService"; +import {NumbersSession} from "./NumbersSession"; +import {ApiService} from "../../../common/api.service"; +import {Router} from "@angular/router"; +import {UserService} from "../../../User/user.service"; + +@Injectable({ + providedIn: 'root' +}) +export class NumbersSessionService extends AbstractSessionService { + + constructor( + api: ApiService, + router: Router, + userService: UserService + ) { + super(api, router, userService, ['Numbers'], ['Numbers', 'Session'], NumbersSession.fromJson); + } + +} diff --git a/src/main/angular/src/app/app.component.html b/src/main/angular/src/app/app.component.html index 5149855..622fee5 100644 --- a/src/main/angular/src/app/app.component.html +++ b/src/main/angular/src/app/app.component.html @@ -1,5 +1,10 @@ diff --git a/src/main/angular/src/app/app.component.less b/src/main/angular/src/app/app.component.less index 5434010..aca454c 100644 --- a/src/main/angular/src/app/app.component.less +++ b/src/main/angular/src/app/app.component.less @@ -7,6 +7,12 @@ border-right: 1px solid black; } + .mainMenuItemRight { + float: right; + border-left: 1px solid black; + border-right: none; + } + .mainMenuItemActive { background-color: lightskyblue; } diff --git a/src/main/angular/src/app/app.component.ts b/src/main/angular/src/app/app.component.ts index 7013e4b..4430912 100644 --- a/src/main/angular/src/app/app.component.ts +++ b/src/main/angular/src/app/app.component.ts @@ -1,6 +1,10 @@ -import {Component} from '@angular/core'; -import {RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router'; +import {Component, OnDestroy, OnInit} from '@angular/core'; +import {Router, RouterLink, RouterLinkActive, RouterOutlet} from '@angular/router'; import {NgIf} from "@angular/common"; +import {UserService} from "./api/User/user.service"; +import {NumbersSession} from "./api/tools/Numbers/Session/NumbersSession"; +import {UserPrivate} from "./api/User/UserPrivate"; +import {Subscription} from "rxjs"; @Component({ selector: 'app-root', @@ -9,12 +13,42 @@ import {NgIf} from "@angular/common"; templateUrl: './app.component.html', styleUrl: './app.component.less' }) -export class AppComponent { +export class AppComponent implements OnInit, OnDestroy { + + protected readonly NumbersSession = NumbersSession; + + private readonly subscriptions: Subscription[] = []; protected menuVisible = true; - onActivate(component: any) { + protected user: UserPrivate | null = null; + + constructor( + protected readonly router: Router, + protected readonly userService: UserService, + ) { + // - + } + + protected onActivate(component: any) { this.menuVisible = !("PRINTABLE_ONLY" in component) || !component.PRINTABLE_ONLY; } + ngOnInit(): void { + this.subscriptions.push(this.userService.subscribe(user => { + if (this.user !== null && user === null) { + this.router.navigate(['/']); + } + this.user = user; + })); + } + + ngOnDestroy(): void { + this.subscriptions.forEach(subscription => subscription.unsubscribe()); + } + + delete() { + this.userService.delete(); + } + } diff --git a/src/main/angular/src/app/app.config.ts b/src/main/angular/src/app/app.config.ts index d143aad..7bde281 100644 --- a/src/main/angular/src/app/app.config.ts +++ b/src/main/angular/src/app/app.config.ts @@ -5,6 +5,9 @@ import {routes} from './app.routes'; import {registerLocaleData} from "@angular/common"; import localeDe from '@angular/common/locales/de'; import localeDeExtra from '@angular/common/locales/extra/de'; +import {provideHttpClient} from "@angular/common/http"; +import {stompServiceFactory} from "./api/common/ws"; +import {StompService} from "@stomp/ng2-stompjs"; registerLocaleData(localeDe, 'de-DE', localeDeExtra); @@ -13,5 +16,7 @@ export const appConfig: ApplicationConfig = { {provide: LOCALE_ID, useValue: 'de-DE'}, provideZoneChangeDetection({eventCoalescing: true}), provideRouter(routes), + provideHttpClient(), + {provide: StompService, useFactory: stompServiceFactory}, ] }; diff --git a/src/main/angular/src/app/app.routes.ts b/src/main/angular/src/app/app.routes.ts index cb04ff1..ba2de12 100644 --- a/src/main/angular/src/app/app.routes.ts +++ b/src/main/angular/src/app/app.routes.ts @@ -1,16 +1,28 @@ import {Routes} from '@angular/router'; -import {SolarSystemComponent} from './solar-system/solar-system.component'; -import {VoltageDropComponent} from "./voltage-drop/voltage-drop.component"; -import {SolarSystemPrintoutComponent} from "./solar-system/printout/solar-system-printout.component"; +import {SolarSystemComponent} from './pages/tools/solar-system/solar-system.component'; +import {VoltageDropComponent} from "./pages/tools/voltage-drop/voltage-drop.component"; +import {SolarSystemPrintoutComponent} from "./pages/tools/solar-system/printout/solar-system-printout.component"; +import {NumbersOverviewComponent} from "./pages/tools/numbers/overview/numbers-overview.component"; +import {ProfileComponent} from "./pages/profile/profile.component"; +import {NumbersSessionComponent} from "./pages/tools/numbers/session/numbers-session.component"; +import {UserComponent} from "./pages/user/user.component"; export const routes: Routes = [ {path: 'SolarSystemPrintout', component: SolarSystemPrintoutComponent}, {path: 'SolarSystem', component: SolarSystemComponent}, + {path: 'VoltageDrop', component: VoltageDropComponent}, + {path: 'Numbers', component: NumbersOverviewComponent}, + {path: 'Numbers/Session', component: NumbersSessionComponent}, + + {path: 'User', component: UserComponent}, + + {path: 'Profile', component: ProfileComponent}, + // historic {path: 'Solar', redirectTo: '/SolarSystem'}, // fallback - {path: '**', redirectTo: '/Solar'}, + {path: '**', redirectTo: '/SolarSystem'}, ]; diff --git a/src/main/angular/src/app/pages/profile/profile.component.html b/src/main/angular/src/app/pages/profile/profile.component.html new file mode 100644 index 0000000..0b35979 --- /dev/null +++ b/src/main/angular/src/app/pages/profile/profile.component.html @@ -0,0 +1,5 @@ + +

Profil

+ + +
diff --git a/src/main/angular/src/app/pages/profile/profile.component.less b/src/main/angular/src/app/pages/profile/profile.component.less new file mode 100644 index 0000000..e69de29 diff --git a/src/main/angular/src/app/pages/profile/profile.component.ts b/src/main/angular/src/app/pages/profile/profile.component.ts new file mode 100644 index 0000000..13993ea --- /dev/null +++ b/src/main/angular/src/app/pages/profile/profile.component.ts @@ -0,0 +1,29 @@ +import {Component} from '@angular/core'; +import {NgForOf, NgIf} from "@angular/common"; +import {UserService} from "../../api/User/user.service"; +import {FormsModule} from "@angular/forms"; +import {TextComponent} from "../../shared/text/text.component"; +import {SessionListComponent} from "../tools/numbers/shared/session-list/session-list.component"; + +@Component({ + selector: 'app-profile', + standalone: true, + imports: [ + NgForOf, + NgIf, + FormsModule, + TextComponent, + SessionListComponent + ], + templateUrl: './profile.component.html', + styleUrl: './profile.component.less' +}) +export class ProfileComponent { + + constructor( + protected readonly userService: UserService, + ) { + // - + } + +} diff --git a/src/main/angular/src/app/pages/tools/numbers/numbers-common.less b/src/main/angular/src/app/pages/tools/numbers/numbers-common.less new file mode 100644 index 0000000..6a4f2ac --- /dev/null +++ b/src/main/angular/src/app/pages/tools/numbers/numbers-common.less @@ -0,0 +1,2 @@ +@import "../../../../tile.less"; +@import "../../user/user.less"; diff --git a/src/main/angular/src/app/pages/tools/numbers/overview/numbers-overview.component.html b/src/main/angular/src/app/pages/tools/numbers/overview/numbers-overview.component.html new file mode 100644 index 0000000..f237571 --- /dev/null +++ b/src/main/angular/src/app/pages/tools/numbers/overview/numbers-overview.component.html @@ -0,0 +1,25 @@ +
+ +
+
+
+ Teilnahmen +
+
+ +
+
+
+ +
+
+
+ Neues Spiel erstellen +
+
+ +
+
+
+ +
diff --git a/src/main/angular/src/app/pages/tools/numbers/overview/numbers-overview.component.less b/src/main/angular/src/app/pages/tools/numbers/overview/numbers-overview.component.less new file mode 100644 index 0000000..239147d --- /dev/null +++ b/src/main/angular/src/app/pages/tools/numbers/overview/numbers-overview.component.less @@ -0,0 +1 @@ +@import "../numbers-common.less"; diff --git a/src/main/angular/src/app/pages/tools/numbers/overview/numbers-overview.component.ts b/src/main/angular/src/app/pages/tools/numbers/overview/numbers-overview.component.ts new file mode 100644 index 0000000..350bbcf --- /dev/null +++ b/src/main/angular/src/app/pages/tools/numbers/overview/numbers-overview.component.ts @@ -0,0 +1,32 @@ +import {Component} from '@angular/core'; +import {UserService} from "../../../../api/User/user.service"; +import {DatePipe, NgForOf, NgIf} from "@angular/common"; +import {NumbersSessionService} from "../../../../api/tools/Numbers/Session/numbers-session.service"; +import {SessionListComponent} from "../shared/session-list/session-list.component"; + +@Component({ + selector: 'app-numbers-overview', + standalone: true, + imports: [ + NgForOf, + NgIf, + DatePipe, + SessionListComponent + ], + templateUrl: './numbers-overview.component.html', + styleUrl: './numbers-overview.component.less' +}) +export class NumbersOverviewComponent { + + constructor( + protected readonly userService: UserService, + protected readonly numberSessionService: NumbersSessionService, + ) { + // - + } + + create() { + this.numberSessionService.create(session => this.numberSessionService.goto(session)); + } + +} diff --git a/src/main/angular/src/app/pages/tools/numbers/session/numbers-session.component.html b/src/main/angular/src/app/pages/tools/numbers/session/numbers-session.component.html new file mode 100644 index 0000000..e919d0f --- /dev/null +++ b/src/main/angular/src/app/pages/tools/numbers/session/numbers-session.component.html @@ -0,0 +1,38 @@ + +

{{ session.typeDisplayName }}

+ + + + + + + + + + + + + + + + + + + + + +
Erstellt{{ session.created | date:'yyyy-MM-dd HH:mm' }}
Titel + + +
Passwort + + +
Besitzer{{ session.owner.name }}
Teilnehmer +
{{ user.name }}
+
+
+ + +

Passwort

+ +
diff --git a/src/main/angular/src/app/pages/tools/numbers/session/numbers-session.component.less b/src/main/angular/src/app/pages/tools/numbers/session/numbers-session.component.less new file mode 100644 index 0000000..239147d --- /dev/null +++ b/src/main/angular/src/app/pages/tools/numbers/session/numbers-session.component.less @@ -0,0 +1 @@ +@import "../numbers-common.less"; diff --git a/src/main/angular/src/app/pages/tools/numbers/session/numbers-session.component.ts b/src/main/angular/src/app/pages/tools/numbers/session/numbers-session.component.ts new file mode 100644 index 0000000..f7757f1 --- /dev/null +++ b/src/main/angular/src/app/pages/tools/numbers/session/numbers-session.component.ts @@ -0,0 +1,76 @@ +import {Component, OnInit} from '@angular/core'; +import {ActivatedRoute, Router} from "@angular/router"; +import {NumbersSession} from "../../../../api/tools/Numbers/Session/NumbersSession"; +import {DatePipe, NgForOf, NgIf} from "@angular/common"; +import {UserService} from "../../../../api/User/user.service"; +import {NumbersSessionService} from "../../../../api/tools/Numbers/Session/numbers-session.service"; +import {FormsModule} from "@angular/forms"; +import {TextComponent} from "../../../../shared/text/text.component"; + +@Component({ + selector: 'app-numbers-session', + standalone: true, + imports: [ + NgForOf, + NgIf, + DatePipe, + FormsModule, + TextComponent + ], + templateUrl: './numbers-session.component.html', + styleUrl: './numbers-session.component.less' +}) +export class NumbersSessionComponent implements OnInit { + + protected session: NumbersSession | null = null; + + protected uuid: string | null = null; + + protected accessDenied: boolean | null = null; + + protected password: string = ""; + + constructor( + protected readonly router: Router, + protected readonly activatedRoute: ActivatedRoute, + protected readonly numbersSessionService: NumbersSessionService, + protected readonly userService: UserService, + ) { + // - + } + + ngOnInit(): void { + this.password = ""; + this.accessDenied = null; + this.activatedRoute.params.subscribe(params => { + this.uuid = params['uuid']; + if (this.uuid) { + this.numbersSessionService.canAccess(this.uuid, canAccess => { + this.accessDenied = !canAccess; + if (canAccess && this.uuid) { + this.numbersSessionService.get(this.uuid, session => this.setSession(session)); + } + }); + } + }); + } + + private setSession(session: NumbersSession): void { + this.session = session; + } + + protected join() { + if (this.uuid) { + this.numbersSessionService.join(this.uuid, this.password, session => this.setSession(session)); + } + } + + protected changeTitle(session: NumbersSession, title: string) { + this.numbersSessionService.changeTitle(session, title, session => this.setSession(session)); + } + + protected changePassword(session: NumbersSession, password: string) { + this.numbersSessionService.changePassword(session, password, session => this.setSession(session)); + } + +} diff --git a/src/main/angular/src/app/pages/tools/numbers/shared/session-list/session-list.component.html b/src/main/angular/src/app/pages/tools/numbers/shared/session-list/session-list.component.html new file mode 100644 index 0000000..8241a78 --- /dev/null +++ b/src/main/angular/src/app/pages/tools/numbers/shared/session-list/session-list.component.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + +
TypTitelBesitzerErstelltTeilnehmer
{{ session.typeDisplayName }}{{ session.title }}{{ session.owner.name }}{{ session.created | date:'yyyy-MM-dd hh:mm' }} +
+ {{ user.name }} +
+
diff --git a/src/main/angular/src/app/pages/tools/numbers/shared/session-list/session-list.component.less b/src/main/angular/src/app/pages/tools/numbers/shared/session-list/session-list.component.less new file mode 100644 index 0000000..38e483e --- /dev/null +++ b/src/main/angular/src/app/pages/tools/numbers/shared/session-list/session-list.component.less @@ -0,0 +1 @@ +@import "../../numbers-common.less"; diff --git a/src/main/angular/src/app/pages/tools/numbers/shared/session-list/session-list.component.ts b/src/main/angular/src/app/pages/tools/numbers/shared/session-list/session-list.component.ts new file mode 100644 index 0000000..c63ae8a --- /dev/null +++ b/src/main/angular/src/app/pages/tools/numbers/shared/session-list/session-list.component.ts @@ -0,0 +1,32 @@ +import {Component, Input} from '@angular/core'; +import {UserService} from "../../../../../api/User/user.service"; +import {NumbersSessionService} from "../../../../../api/tools/Numbers/Session/numbers-session.service"; +import {AbstractSession} from "../../../../../api/Session/AbstractSession"; +import {DatePipe, NgForOf} from "@angular/common"; + +@Component({ + selector: 'app-session-list', + standalone: true, + imports: [ + DatePipe, + NgForOf + ], + templateUrl: './session-list.component.html', + styleUrl: './session-list.component.less' +}) +export class SessionListComponent { + + @Input() sessions!: AbstractSession[]; + + constructor( + protected readonly userService: UserService, + protected readonly numbersSessionService: NumbersSessionService, + ) { + // - + } + + create() { + this.numbersSessionService.create(session => this.numbersSessionService.goto(session)); + } + +} diff --git a/src/main/angular/src/app/solar-system/SOLAR_SYSTEM.ts b/src/main/angular/src/app/pages/tools/solar-system/SOLAR_SYSTEM.ts similarity index 100% rename from src/main/angular/src/app/solar-system/SOLAR_SYSTEM.ts rename to src/main/angular/src/app/pages/tools/solar-system/SOLAR_SYSTEM.ts diff --git a/src/main/angular/src/app/solar-system/SolarSystemBody.ts b/src/main/angular/src/app/pages/tools/solar-system/SolarSystemBody.ts similarity index 100% rename from src/main/angular/src/app/solar-system/SolarSystemBody.ts rename to src/main/angular/src/app/pages/tools/solar-system/SolarSystemBody.ts diff --git a/src/main/angular/src/app/solar-system/printout/solar-system-printout.component.html b/src/main/angular/src/app/pages/tools/solar-system/printout/solar-system-printout.component.html similarity index 100% rename from src/main/angular/src/app/solar-system/printout/solar-system-printout.component.html rename to src/main/angular/src/app/pages/tools/solar-system/printout/solar-system-printout.component.html diff --git a/src/main/angular/src/app/solar-system/printout/solar-system-printout.component.less b/src/main/angular/src/app/pages/tools/solar-system/printout/solar-system-printout.component.less similarity index 100% rename from src/main/angular/src/app/solar-system/printout/solar-system-printout.component.less rename to src/main/angular/src/app/pages/tools/solar-system/printout/solar-system-printout.component.less diff --git a/src/main/angular/src/app/solar-system/printout/solar-system-printout.component.ts b/src/main/angular/src/app/pages/tools/solar-system/printout/solar-system-printout.component.ts similarity index 96% rename from src/main/angular/src/app/solar-system/printout/solar-system-printout.component.ts rename to src/main/angular/src/app/pages/tools/solar-system/printout/solar-system-printout.component.ts index 8713e7c..2a9b017 100644 --- a/src/main/angular/src/app/solar-system/printout/solar-system-printout.component.ts +++ b/src/main/angular/src/app/pages/tools/solar-system/printout/solar-system-printout.component.ts @@ -3,7 +3,7 @@ import {BODIES, BODIES_PRINT, EARTH, EARTH_MOON, JUPITER, JUPITER_SCALED_DIAMETE import {ActivatedRoute, Params} from "@angular/router"; import {DecimalPipe, NgForOf, NgIf} from "@angular/common"; import {MIO_KILO, SolarSystemBody} from "../SolarSystemBody"; -import {Unit} from "../../Unit"; +import {Unit} from "../../../../Unit"; function getScale(params: Params) { if ('scale' in params) { @@ -41,12 +41,10 @@ export class SolarSystemPrintoutComponent implements OnInit { public readonly PRINTABLE_ONLY: boolean = true; - protected readonly BODIES_PRINT = BODIES_PRINT; - - protected readonly SolarSystemBody = SolarSystemBody; - protected readonly makePaler = makePaler; + protected readonly BODIES_PRINT = BODIES_PRINT; + protected readonly MIO_KILO = MIO_KILO; protected readonly EARTH = EARTH; diff --git a/src/main/angular/src/app/solar-system/solar-system.component.html b/src/main/angular/src/app/pages/tools/solar-system/solar-system.component.html similarity index 100% rename from src/main/angular/src/app/solar-system/solar-system.component.html rename to src/main/angular/src/app/pages/tools/solar-system/solar-system.component.html diff --git a/src/main/angular/src/app/solar-system/solar-system.component.less b/src/main/angular/src/app/pages/tools/solar-system/solar-system.component.less similarity index 68% rename from src/main/angular/src/app/solar-system/solar-system.component.less rename to src/main/angular/src/app/pages/tools/solar-system/solar-system.component.less index 2ee3b56..e7d506a 100644 --- a/src/main/angular/src/app/solar-system/solar-system.component.less +++ b/src/main/angular/src/app/pages/tools/solar-system/solar-system.component.less @@ -1,13 +1,4 @@ -@import "../../tile.less"; - -table { - width: 100%; -} - -td { - white-space: nowrap; - border: 0.2em solid white; -} +@import "../../../../tile.less"; .name { text-align: left; diff --git a/src/main/angular/src/app/solar-system/solar-system.component.ts b/src/main/angular/src/app/pages/tools/solar-system/solar-system.component.ts similarity index 96% rename from src/main/angular/src/app/solar-system/solar-system.component.ts rename to src/main/angular/src/app/pages/tools/solar-system/solar-system.component.ts index 1b3d764..a6e71cb 100644 --- a/src/main/angular/src/app/solar-system/solar-system.component.ts +++ b/src/main/angular/src/app/pages/tools/solar-system/solar-system.component.ts @@ -3,7 +3,7 @@ import {DecimalPipe, NgForOf} from "@angular/common"; import {FormsModule} from "@angular/forms"; import {MIO_KILO, SolarSystemBody} from "./SolarSystemBody"; import {BODIES, JUPITER, JUPITER_SCALED_DIAMETER} from "./SOLAR_SYSTEM"; -import {applyPrefixUnit, Unit} from "../Unit"; +import {applyPrefixUnit, Unit} from "../../../Unit"; import {RouterLink} from "@angular/router"; @Component({ diff --git a/src/main/angular/src/app/voltage-drop/voltage-drop.component.html b/src/main/angular/src/app/pages/tools/voltage-drop/voltage-drop.component.html similarity index 100% rename from src/main/angular/src/app/voltage-drop/voltage-drop.component.html rename to src/main/angular/src/app/pages/tools/voltage-drop/voltage-drop.component.html diff --git a/src/main/angular/src/app/voltage-drop/voltage-drop.component.less b/src/main/angular/src/app/pages/tools/voltage-drop/voltage-drop.component.less similarity index 85% rename from src/main/angular/src/app/voltage-drop/voltage-drop.component.less rename to src/main/angular/src/app/pages/tools/voltage-drop/voltage-drop.component.less index 3a9d5a9..4642f39 100644 --- a/src/main/angular/src/app/voltage-drop/voltage-drop.component.less +++ b/src/main/angular/src/app/pages/tools/voltage-drop/voltage-drop.component.less @@ -1,4 +1,4 @@ -@import "../../tile.less"; +@import "../../../../tile.less"; #VoltageDropInputs { @@ -18,14 +18,14 @@ } .shortcuts { - padding-top: calc(@space / 2); + padding-top: @halfSpace; padding-bottom: calc(@space * 2); .shortcut { float: left; font-size: 80%; - padding: calc(@space / 2) @space; - margin-right: calc(@space / 2); + padding: @halfSpace @space; + margin-right: @halfSpace; background-color: lightskyblue; border-radius: @space; } diff --git a/src/main/angular/src/app/voltage-drop/voltage-drop.component.ts b/src/main/angular/src/app/pages/tools/voltage-drop/voltage-drop.component.ts similarity index 100% rename from src/main/angular/src/app/voltage-drop/voltage-drop.component.ts rename to src/main/angular/src/app/pages/tools/voltage-drop/voltage-drop.component.ts diff --git a/src/main/angular/src/app/pages/user/user.component.html b/src/main/angular/src/app/pages/user/user.component.html new file mode 100644 index 0000000..ea4435c --- /dev/null +++ b/src/main/angular/src/app/pages/user/user.component.html @@ -0,0 +1,4 @@ + +

Benutzer: {{ user.name }}

+ +
diff --git a/src/main/angular/src/app/pages/user/user.component.less b/src/main/angular/src/app/pages/user/user.component.less new file mode 100644 index 0000000..e69de29 diff --git a/src/main/angular/src/app/pages/user/user.component.ts b/src/main/angular/src/app/pages/user/user.component.ts new file mode 100644 index 0000000..368498f --- /dev/null +++ b/src/main/angular/src/app/pages/user/user.component.ts @@ -0,0 +1,39 @@ +import {Component, OnInit} from '@angular/core'; +import {NgForOf, NgIf} from "@angular/common"; +import {ActivatedRoute} from "@angular/router"; +import {UserService} from "../../api/User/user.service"; +import {SessionListComponent} from "../tools/numbers/shared/session-list/session-list.component"; +import {UserCommon} from "../../api/User/UserCommon"; + +@Component({ + selector: 'app-user', + standalone: true, + imports: [ + NgForOf, + NgIf, + SessionListComponent + ], + templateUrl: './user.component.html', + styleUrl: './user.component.less' +}) +export class UserComponent implements OnInit { + + protected user: UserCommon | null = null; + + constructor( + protected readonly activatedRoute: ActivatedRoute, + protected readonly userService: UserService, + ) { + // - + } + + ngOnInit(): void { + this.activatedRoute.params.subscribe(params => { + const uuid = params['uuid']; + if (uuid) { + this.userService.getCommonByUuid(uuid, user => this.user = user); + } + }); + } + +} diff --git a/src/main/angular/src/app/pages/user/user.less b/src/main/angular/src/app/pages/user/user.less new file mode 100644 index 0000000..06d2f81 --- /dev/null +++ b/src/main/angular/src/app/pages/user/user.less @@ -0,0 +1,10 @@ +.user { + float: left; + padding: @halfSpace; + background-color: lightskyblue; + border-radius: @halfSpace; +} + +.user:hover { + background-color: dodgerblue; +} diff --git a/src/main/angular/src/app/shared/text/text.component.html b/src/main/angular/src/app/shared/text/text.component.html new file mode 100644 index 0000000..a88214f --- /dev/null +++ b/src/main/angular/src/app/shared/text/text.component.html @@ -0,0 +1 @@ + diff --git a/src/main/angular/src/app/shared/text/text.component.less b/src/main/angular/src/app/shared/text/text.component.less new file mode 100644 index 0000000..e69de29 diff --git a/src/main/angular/src/app/shared/text/text.component.ts b/src/main/angular/src/app/shared/text/text.component.ts new file mode 100644 index 0000000..e292164 --- /dev/null +++ b/src/main/angular/src/app/shared/text/text.component.ts @@ -0,0 +1,56 @@ +import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; +import {FormsModule} from "@angular/forms"; + +@Component({ + selector: 'app-text', + standalone: true, + imports: [ + FormsModule + ], + templateUrl: './text.component.html', + styleUrl: './text.component.less' +}) +export class TextComponent implements OnInit { + + private _initial: string = ""; + + protected model: string = this._initial; + + protected editing: boolean = false; + + @Output() + readonly onChange = new EventEmitter(); + + @Input() + set initial(value: string) { + this._initial = value; + if (!this.editing) { + this.model = this._initial; + } + } + + ngOnInit(): void { + this.model = this._initial; + } + + begin() { + this.editing = true; + } + + apply() { + if (this.model !== this._initial) { + this.onChange.emit(this.model); + } + this.end(); + } + + abort() { + this.model = this._initial; + this.end(); + } + + end() { + this.editing = false; + } + +} diff --git a/src/main/angular/src/config.less b/src/main/angular/src/config.less index f71af77..b5b354c 100644 --- a/src/main/angular/src/config.less +++ b/src/main/angular/src/config.less @@ -1 +1,2 @@ @space: 0.5em; +@halfSpace: calc(@space / 2); diff --git a/src/main/angular/src/environments/environment.prod.ts b/src/main/angular/src/environments/environment.prod.ts new file mode 100644 index 0000000..80ad8e9 --- /dev/null +++ b/src/main/angular/src/environments/environment.prod.ts @@ -0,0 +1,7 @@ +export const environment = { + production: true, + host: window.location.host.split(":")[0], + port: window.location.port, + base: 'Data/', + secure: window.location.protocol === "https:", +}; diff --git a/src/main/angular/src/environments/environment.ts b/src/main/angular/src/environments/environment.ts new file mode 100644 index 0000000..8b27d93 --- /dev/null +++ b/src/main/angular/src/environments/environment.ts @@ -0,0 +1,8 @@ +export const environment = { + production: false, + host: window.location.host.split(":")[0], + port: 8080, + base: '', + secure: window.location.protocol === "https:", +}; + diff --git a/src/main/angular/src/tile.less b/src/main/angular/src/tile.less index c28a78b..e6f6785 100644 --- a/src/main/angular/src/tile.less +++ b/src/main/angular/src/tile.less @@ -1,11 +1,11 @@ @import "config.less"; .tileContainer { - padding: calc(@space / 2); + padding: @halfSpace; .tile { width: 100%; - padding: calc(@space / 2); + padding: @halfSpace; .tileInner { border: 1px solid #ddd; @@ -14,12 +14,22 @@ .tileTitle { font-weight: bold; - padding: calc(@space / 2) @space; + padding: @halfSpace @space; background-color: lightskyblue; } .tileContent { - padding: calc(@space / 2); + padding: @halfSpace; + + table { + width: 100%; + } + + td { + white-space: nowrap; + border: 0.2em solid white; + } + } } diff --git a/src/main/java/de/ph87/tools/UserArgumentResolver.java b/src/main/java/de/ph87/tools/UserArgumentResolver.java new file mode 100644 index 0000000..8979476 --- /dev/null +++ b/src/main/java/de/ph87/tools/UserArgumentResolver.java @@ -0,0 +1,40 @@ +package de.ph87.tools; + +import de.ph87.tools.user.User; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.NonNull; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.Arrays; + +@Component +public class UserArgumentResolver implements HandlerMethodArgumentResolver { + + public static final String USER_UUID_COOKIE_NAME = "PatrixToolsUserUuid"; + + @Override + public boolean supportsParameter(@NonNull final MethodParameter parameter) { + return parameter.getParameterType() == User.class; + } + + @Override + public User resolveArgument(@NonNull final MethodParameter parameter, final ModelAndViewContainer mavContainer, @NonNull final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) { + if (!(webRequest instanceof final HttpServletRequest request)) { + return null; + } + + final String uuid = Arrays.stream(request.getCookies()).filter(cookie -> USER_UUID_COOKIE_NAME.equalsIgnoreCase(cookie.getName())).findFirst().map(Cookie::getValue).orElse(null); + if (uuid == null) { + return null; + } + + return null; + } + +} \ No newline at end of file diff --git a/src/main/java/de/ph87/tools/session/AbstractSession.java b/src/main/java/de/ph87/tools/session/AbstractSession.java new file mode 100644 index 0000000..5916bc8 --- /dev/null +++ b/src/main/java/de/ph87/tools/session/AbstractSession.java @@ -0,0 +1,78 @@ +package de.ph87.tools.session; + +import de.ph87.tools.user.User; +import de.ph87.tools.web.IWebSocketMessage; +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; +import lombok.ToString; + +import java.time.ZonedDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Getter +@ToString +public abstract class AbstractSession implements IWebSocketMessage { + + @NonNull + public final String uuid = UUID.randomUUID().toString(); + + @NonNull + public final User owner; + + @NonNull + public final ZonedDateTime created = ZonedDateTime.now(); + + @NonNull + @ToString.Exclude + private final Set users = new HashSet<>(); + + @Setter + @NonNull + public String title = "Spiel ohne Namen"; + + @Setter + @NonNull + @ToString.Exclude + private String password = UUID.randomUUID().toString().substring(0, 4); + + @Setter + private boolean initial = true; + + @NonNull + private ZonedDateTime lastAccess = created; + + protected AbstractSession(@NonNull final User user) { + this.owner = user; + this.join(user); + } + + public void join(@NonNull final User user) { + synchronized (uuid) { + users.add(user); + touch(); + user.join(this); + } + } + + public void leave(@NonNull final User user) { + synchronized (uuid) { + users.remove(user); + touch(); + user.leave(this); + } + } + + private void touch() { + lastAccess = ZonedDateTime.now(); + } + + @Override + public List getWebsocketTopic() { + return List.of("Number", uuid); + } + +} diff --git a/src/main/java/de/ph87/tools/session/AbstractSessionController.java b/src/main/java/de/ph87/tools/session/AbstractSessionController.java new file mode 100644 index 0000000..c2f61a6 --- /dev/null +++ b/src/main/java/de/ph87/tools/session/AbstractSessionController.java @@ -0,0 +1,154 @@ +package de.ph87.tools.session; + +import de.ph87.tools.user.User; +import de.ph87.tools.user.UserService; +import jakarta.annotation.Nullable; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.time.ZonedDateTime; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static de.ph87.tools.UserArgumentResolver.USER_UUID_COOKIE_NAME; + +@Slf4j +@CrossOrigin +@EnableScheduling +@RequiredArgsConstructor +public abstract class AbstractSessionController { + + private final Set sessions = new HashSet<>(); + + private final UserService userService; + + private final ApplicationEventPublisher applicationEventPublisher; + + private final Class sessionClazz; + + @Scheduled(timeUnit = TimeUnit.MINUTES, initialDelay = 5, fixedRate = 5) + public void cleanUp() { + final ZonedDateTime deadline = ZonedDateTime.now().minusDays(1); + synchronized (sessions) { + sessions.removeIf(session -> session.getLastAccess().isBefore(deadline)); + } + } + + @GetMapping("create") + public AbstractSessionDto create(@CookieValue(name = USER_UUID_COOKIE_NAME, required = false) @Nullable final String userUuid, @NonNull final HttpServletResponse response) { + final User user = userService.getUserByUuidOrElseCreate(userUuid, response); + + final Optional existing = user.getSessions().stream().filter(sessionClazz::isInstance).filter(AbstractSession::isInitial).map(sessionClazz::cast).min(Comparator.comparing(AbstractSession::getCreated)); + if (existing.isPresent()) { + log.info("User wanted to create new Session but found existing: {}", existing); + return toDto(existing.get(), user); + } + + final SESSION session = create(user); + log.info("Session CREATED: {}", session); + synchronized (sessions) { + sessions.add(session); + } + + applicationEventPublisher.publishEvent(session); + applicationEventPublisher.publishEvent(user); + return toDto(session, user); + } + + @PostMapping("canAccess") + public boolean canAccess(@CookieValue(name = USER_UUID_COOKIE_NAME, required = false) @Nullable final String userUuid, @RequestBody final String sessionUuid) { + if (userUuid == null) { + return false; + } + return userService.findByPrivateUuid(userUuid).flatMap(user -> findUserSession(user, sessionUuid)).isPresent(); + } + + @PostMapping("get") + public AbstractSessionDto get(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @RequestBody final String sessionUuid) { + final User user = userService.getByPrivateUuidOrThrow(userUuid); + final SESSION session = getUserSession(user, sessionUuid); + return toDto(session, user); + } + + @PostMapping("join") + public AbstractSessionDto join(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @RequestBody final SessionJoinInbound inbound) { + final User user = userService.getByPrivateUuidOrThrow(userUuid); + final SESSION session = getSessionByUuid(inbound.uuid); + if (!session.getPassword().equals(inbound.password)) { + log.error("Wrong password: user={}, session={}", user, session); + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + session.join(user); + applicationEventPublisher.publishEvent(session); + applicationEventPublisher.publishEvent(user); + return toDto(session, user); + } + + @PostMapping("changeTitle") + public AbstractSessionDto changeTitle(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @RequestBody final SessionChangeTitleInbound inbound) { + final User user = userService.getByPrivateUuidOrThrow(userUuid); + final SESSION session = getUserSession(user, inbound.uuid); + if (session.owner != user) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + session.setTitle(inbound.title); + applicationEventPublisher.publishEvent(session); + return toDto(session, user); + } + + @PostMapping("changePassword") + public AbstractSessionDto changePassword(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @RequestBody final SessionChangePasswordInbound inbound) { + final User user = userService.getByPrivateUuidOrThrow(userUuid); + final SESSION session = getUserSession(user, inbound.uuid); + if (session.owner != user) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } + session.setPassword(inbound.password); + applicationEventPublisher.publishEvent(session); + return toDto(session, user); + } + + @PostMapping("leave") + public void leave(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @RequestBody final String sessionUuid) { + final User user = userService.getByPrivateUuidOrThrow(userUuid); + final SESSION session = getSessionByUuid(sessionUuid); + session.leave(user); + applicationEventPublisher.publishEvent(session); + applicationEventPublisher.publishEvent(user); + } + + @NonNull + private SESSION getUserSession(@NonNull final User user, @NonNull final String sessionUuid) { + return findUserSession(user, sessionUuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); + } + + @NonNull + private Optional findUserSession(@NonNull final User user, @NonNull final String sessionUuid) { + return user.getSessions().stream().filter(s -> s.uuid.equals(sessionUuid)).filter(sessionClazz::isInstance).map(sessionClazz::cast).findFirst(); + } + + @NonNull + private SESSION getSessionByUuid(@NonNull final String uuid) { + synchronized (sessions) { + return sessions.stream().filter(u -> u.getUuid().equals(uuid)).findFirst().orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); + } + } + + @NonNull + protected abstract SESSION create(@NonNull final User user); + + @NonNull + protected abstract AbstractSessionDto toDto(@NonNull final SESSION session, @NonNull final User forUser); + +} diff --git a/src/main/java/de/ph87/tools/session/AbstractSessionDto.java b/src/main/java/de/ph87/tools/session/AbstractSessionDto.java new file mode 100644 index 0000000..5881d4a --- /dev/null +++ b/src/main/java/de/ph87/tools/session/AbstractSessionDto.java @@ -0,0 +1,60 @@ +package de.ph87.tools.session; + +import de.ph87.tools.tools.numbers.NumbersSession; +import de.ph87.tools.tools.numbers.NumbersSessionDto; +import de.ph87.tools.user.UserPublicDto; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +import java.time.ZonedDateTime; +import java.util.Set; +import java.util.stream.Collectors; + +@Getter +@ToString +public abstract class AbstractSessionDto { + + @NonNull + private final String type; + + @NonNull + public final String uuid; + + @NonNull + public final String title; + + @NonNull + public final ZonedDateTime created; + + @NonNull + public final String password; + + @NonNull + public final UserPublicDto owner; + + @NonNull + public final Set users; + + public final boolean initial; + + protected AbstractSessionDto(@NonNull final AbstractSession session, @NonNull final String type) { + this.type = type; + this.uuid = session.uuid; + this.title = session.title; + this.created = session.created; + this.password = session.getPassword(); + this.owner = new UserPublicDto(session.owner); + this.users = session.getUsers().stream().map(UserPublicDto::new).collect(Collectors.toSet()); + this.initial = session.isInitial(); + } + + @NonNull + public static AbstractSessionDto toDto(@NonNull final AbstractSession session) { + return switch (session) { + case NumbersSession numbersSession -> new NumbersSessionDto(numbersSession); + default -> throw new IllegalStateException("No DTO mapping for: " + session); + }; + } + +} diff --git a/src/main/java/de/ph87/tools/session/SessionChangePasswordInbound.java b/src/main/java/de/ph87/tools/session/SessionChangePasswordInbound.java new file mode 100644 index 0000000..4c4b4c4 --- /dev/null +++ b/src/main/java/de/ph87/tools/session/SessionChangePasswordInbound.java @@ -0,0 +1,19 @@ +package de.ph87.tools.session; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +@Getter +@ToString +@AllArgsConstructor +public class SessionChangePasswordInbound { + + @NonNull + public final String uuid; + + @NonNull + public final String password; + +} diff --git a/src/main/java/de/ph87/tools/session/SessionChangeTitleInbound.java b/src/main/java/de/ph87/tools/session/SessionChangeTitleInbound.java new file mode 100644 index 0000000..8a09ae7 --- /dev/null +++ b/src/main/java/de/ph87/tools/session/SessionChangeTitleInbound.java @@ -0,0 +1,19 @@ +package de.ph87.tools.session; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +@Getter +@ToString +@AllArgsConstructor +public class SessionChangeTitleInbound { + + @NonNull + public final String uuid; + + @NonNull + public final String title; + +} diff --git a/src/main/java/de/ph87/tools/session/SessionJoinInbound.java b/src/main/java/de/ph87/tools/session/SessionJoinInbound.java new file mode 100644 index 0000000..969805b --- /dev/null +++ b/src/main/java/de/ph87/tools/session/SessionJoinInbound.java @@ -0,0 +1,19 @@ +package de.ph87.tools.session; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +@Getter +@ToString +@AllArgsConstructor +public class SessionJoinInbound { + + @NonNull + public final String uuid; + + @NonNull + public final String password; + +} diff --git a/src/main/java/de/ph87/tools/tools/numbers/NumbersController.java b/src/main/java/de/ph87/tools/tools/numbers/NumbersController.java new file mode 100644 index 0000000..967b5cc --- /dev/null +++ b/src/main/java/de/ph87/tools/tools/numbers/NumbersController.java @@ -0,0 +1,32 @@ +package de.ph87.tools.tools.numbers; + +import de.ph87.tools.session.AbstractSessionController; +import de.ph87.tools.session.AbstractSessionDto; +import de.ph87.tools.user.User; +import de.ph87.tools.user.UserService; +import lombok.NonNull; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("Numbers") +public class NumbersController extends AbstractSessionController { + + public NumbersController(final UserService userService, final ApplicationEventPublisher applicationEventPublisher) { + super(userService, applicationEventPublisher, NumbersSession.class); + } + + @NonNull + @Override + protected NumbersSession create(@NonNull final User user) { + return new NumbersSession(user); + } + + @NonNull + @Override + protected AbstractSessionDto toDto(@NonNull final NumbersSession session, @NonNull final User forUser) { + return new NumbersSessionDto(session); + } + +} diff --git a/src/main/java/de/ph87/tools/tools/numbers/NumbersSession.java b/src/main/java/de/ph87/tools/tools/numbers/NumbersSession.java new file mode 100644 index 0000000..55e98cb --- /dev/null +++ b/src/main/java/de/ph87/tools/tools/numbers/NumbersSession.java @@ -0,0 +1,13 @@ +package de.ph87.tools.tools.numbers; + +import de.ph87.tools.session.AbstractSession; +import de.ph87.tools.user.User; +import lombok.NonNull; + +public class NumbersSession extends AbstractSession { + + protected NumbersSession(@NonNull final User user) { + super(user); + } + +} diff --git a/src/main/java/de/ph87/tools/tools/numbers/NumbersSessionDto.java b/src/main/java/de/ph87/tools/tools/numbers/NumbersSessionDto.java new file mode 100644 index 0000000..4c0920c --- /dev/null +++ b/src/main/java/de/ph87/tools/tools/numbers/NumbersSessionDto.java @@ -0,0 +1,16 @@ +package de.ph87.tools.tools.numbers; + +import de.ph87.tools.session.AbstractSessionDto; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +@Getter +@ToString(callSuper = true) +public class NumbersSessionDto extends AbstractSessionDto { + + public NumbersSessionDto(@NonNull final NumbersSession session) { + super(session, "Numbers"); + } + +} diff --git a/src/main/java/de/ph87/tools/user/User.java b/src/main/java/de/ph87/tools/user/User.java new file mode 100644 index 0000000..39df073 --- /dev/null +++ b/src/main/java/de/ph87/tools/user/User.java @@ -0,0 +1,62 @@ +package de.ph87.tools.user; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import de.ph87.tools.session.AbstractSession; +import de.ph87.tools.web.IWebSocketMessage; +import lombok.Getter; +import lombok.NonNull; +import lombok.Setter; +import lombok.ToString; + +import java.time.ZonedDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +@Getter +@ToString +public class User implements IWebSocketMessage { + + @NonNull + public final String privateUuid = UUID.randomUUID().toString(); + + @NonNull + public final String publicUuid = UUID.randomUUID().toString(); + + public final ZonedDateTime created = ZonedDateTime.now(); + + @JsonIgnore + @ToString.Exclude + private final Set sessions = new HashSet<>(); + + private ZonedDateTime lastAccess = created; + + @Setter + @NonNull + public String name = "unnamed"; + + private void touch() { + lastAccess = ZonedDateTime.now(); + } + + public void join(@NonNull final AbstractSession session) { + synchronized (privateUuid) { + sessions.add(session); + touch(); + } + } + + public void leave(@NonNull final AbstractSession session) { + synchronized (privateUuid) { + sessions.remove(session); + touch(); + } + } + + @Override + public List getWebsocketTopic() { + return List.of("User", privateUuid); + } + +} diff --git a/src/main/java/de/ph87/tools/user/UserCommonDto.java b/src/main/java/de/ph87/tools/user/UserCommonDto.java new file mode 100644 index 0000000..60579e2 --- /dev/null +++ b/src/main/java/de/ph87/tools/user/UserCommonDto.java @@ -0,0 +1,34 @@ +package de.ph87.tools.user; + +import de.ph87.tools.session.AbstractSessionDto; +import jakarta.annotation.Nullable; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +import java.util.Set; +import java.util.stream.Collectors; + +@Getter +@ToString +public class UserCommonDto { + + @NonNull + public final String publicUuid; + + @NonNull + public final String name; + + @NonNull + public final Set commonSessions; + + public UserCommonDto(@NonNull final User target, @Nullable final User principal) { + this.publicUuid = target.getPublicUuid(); + this.name = target.getName(); + this.commonSessions = target.getSessions().stream() + .filter(session -> session.getUsers().contains(principal)) + .map(AbstractSessionDto::toDto) + .collect(Collectors.toSet()); + } + +} diff --git a/src/main/java/de/ph87/tools/user/UserController.java b/src/main/java/de/ph87/tools/user/UserController.java new file mode 100644 index 0000000..93bbace --- /dev/null +++ b/src/main/java/de/ph87/tools/user/UserController.java @@ -0,0 +1,44 @@ +package de.ph87.tools.user; + +import jakarta.annotation.Nullable; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import static de.ph87.tools.UserArgumentResolver.USER_UUID_COOKIE_NAME; + +@Slf4j +@CrossOrigin +@RestController +@RequiredArgsConstructor +@RequestMapping("User") +public class UserController { + + private final UserService userService; + + @Nullable + @GetMapping("whoAmI") + public UserPrivateDto whoAmI(@CookieValue(name = USER_UUID_COOKIE_NAME, required = false) @Nullable final String userUuid, @NonNull final HttpServletResponse response) { + return userService.getUserByUuidOrNull(userUuid, response); + } + + @GetMapping("delete") + public void delete(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @NonNull final HttpServletResponse response) { + userService.delete(userUuid, response); + } + + @NonNull + @PostMapping("changeName") + public UserPrivateDto changeName(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @NonNull @RequestBody final String name) { + return userService.changeName(userUuid, name); + } + + @NonNull + @PostMapping("getCommonByUuid") + public UserCommonDto getCommonByUuid(@CookieValue(name = USER_UUID_COOKIE_NAME, required = false) @Nullable final String userUuid, @NonNull @RequestBody final String targetUuid) { + return userService.getCommonByUuid(userUuid, targetUuid); + } + +} diff --git a/src/main/java/de/ph87/tools/user/UserPrivateDto.java b/src/main/java/de/ph87/tools/user/UserPrivateDto.java new file mode 100644 index 0000000..8a503df --- /dev/null +++ b/src/main/java/de/ph87/tools/user/UserPrivateDto.java @@ -0,0 +1,48 @@ +package de.ph87.tools.user; + +import de.ph87.tools.session.AbstractSessionDto; +import jakarta.annotation.Nullable; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +import java.time.ZonedDateTime; +import java.util.Set; +import java.util.stream.Collectors; + +@Getter +@ToString +public class UserPrivateDto { + + @NonNull + public final String publicUuid; + + @NonNull + public final String name; + + @NonNull + public final String privateUuid; + + @NonNull + public final ZonedDateTime created; + + @NonNull + private final Set sessions; + + public UserPrivateDto(@NonNull final User user) { + this.publicUuid = user.getPublicUuid(); + this.name = user.getName(); + this.privateUuid = user.privateUuid; + this.created = user.created; + this.sessions = user.getSessions().stream().map(AbstractSessionDto::toDto).collect(Collectors.toSet()); + } + + @Nullable + public static UserPrivateDto orNull(@Nullable final User user) { + if (user == null) { + return null; + } + return new UserPrivateDto(user); + } + +} diff --git a/src/main/java/de/ph87/tools/user/UserPublicDto.java b/src/main/java/de/ph87/tools/user/UserPublicDto.java new file mode 100644 index 0000000..38d4d17 --- /dev/null +++ b/src/main/java/de/ph87/tools/user/UserPublicDto.java @@ -0,0 +1,30 @@ +package de.ph87.tools.user; + +import de.ph87.tools.web.IWebSocketMessage; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +import java.util.List; + +@Getter +@ToString +public class UserPublicDto implements IWebSocketMessage { + + @NonNull + public final String publicUuid; + + @NonNull + public final String name; + + public UserPublicDto(@NonNull final User user) { + this.publicUuid = user.getPublicUuid(); + this.name = user.getName(); + } + + @Override + public List getWebsocketTopic() { + return List.of("User", publicUuid); + } + +} diff --git a/src/main/java/de/ph87/tools/user/UserService.java b/src/main/java/de/ph87/tools/user/UserService.java new file mode 100644 index 0000000..3569bc2 --- /dev/null +++ b/src/main/java/de/ph87/tools/user/UserService.java @@ -0,0 +1,115 @@ +package de.ph87.tools.user; + +import jakarta.annotation.Nullable; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +import static de.ph87.tools.UserArgumentResolver.USER_UUID_COOKIE_NAME; + +@Slf4j +@Service +@EnableScheduling +@RequiredArgsConstructor +public class UserService { + + private final ApplicationEventPublisher applicationEventPublisher; + + private final Set users = new HashSet<>(); + + @NonNull + public User getUserByUuidOrElseCreate(@Nullable final String uuid, @NonNull final HttpServletResponse response) { + synchronized (users) { + final User user = Optional.ofNullable(uuid).map(this::findByPrivateUuid).filter(Optional::isPresent).map(Optional::get).orElseGet(this::create); + writeUserUuidCookie(response, user); + return user; + } + } + + @Nullable + public UserPrivateDto getUserByUuidOrNull(@Nullable final String userUuid, final @NonNull HttpServletResponse response) { + if (userUuid == null || userUuid.isEmpty()) { + return null; + } + final User user = findByPrivateUuid(userUuid).orElse(null); + writeUserUuidCookie(response, user); + return UserPrivateDto.orNull(user); + } + + public void delete(@NonNull final String userUuid, final @NonNull HttpServletResponse response) { + synchronized (users) { + final User user = findByPrivateUuid(userUuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); + users.remove(user); + log.info("User DELETED: {}", user); + } + writeUserUuidCookie(response, null); + } + + @NonNull + public User getByPrivateUuidOrThrow(@NonNull final String uuid) { + synchronized (users) { + return findByPrivateUuid(uuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); + } + } + + @NonNull + public User getByPublicUuid(@NonNull final String uuid) { + synchronized (users) { + return findByPublicUuid(uuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); + } + } + + @NonNull + public Optional findByPrivateUuid(@NonNull final String uuid) { + return users.stream().filter(u -> u.getPrivateUuid().equals(uuid)).findFirst(); + } + + @NonNull + public Optional findByPublicUuid(@NonNull final String uuid) { + return users.stream().filter(u -> u.getPublicUuid().equals(uuid)).findFirst(); + } + + @NonNull + private User create() { + final User user = new User(); + users.add(user); + log.info("User CREATED: {}", user); + return user; + } + + private static void writeUserUuidCookie(@NonNull final HttpServletResponse response, @Nullable final User user) { + final Cookie userUuidCookie = new Cookie(USER_UUID_COOKIE_NAME, ""); + if (user != null) { + userUuidCookie.setValue(user.privateUuid); + } + userUuidCookie.setPath("/"); + response.addCookie(userUuidCookie); + } + + @NonNull + public UserPrivateDto changeName(@NonNull final String uuid, @NonNull final String name) { + final User user = getByPrivateUuidOrThrow(uuid); + user.setName(name); + applicationEventPublisher.publishEvent(new UserPublicDto(user)); + return new UserPrivateDto(user); + } + + @NonNull + public UserCommonDto getCommonByUuid(@Nullable final String principalUuid, @NonNull final String targetUuid) { + final User principal = principalUuid == null ? null : findByPrivateUuid(principalUuid).orElse(null); + final User target = getByPublicUuid(targetUuid); + return new UserCommonDto(target, principal); + } + +} diff --git a/src/main/java/de/ph87/tools/web/IWebSocketMessage.java b/src/main/java/de/ph87/tools/web/IWebSocketMessage.java new file mode 100644 index 0000000..59df9f9 --- /dev/null +++ b/src/main/java/de/ph87/tools/web/IWebSocketMessage.java @@ -0,0 +1,12 @@ +package de.ph87.tools.web; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.util.List; + +public interface IWebSocketMessage { + + @JsonIgnore + List getWebsocketTopic(); + +} diff --git a/src/main/java/de/ph87/tools/web/WebConfig.java b/src/main/java/de/ph87/tools/web/WebConfig.java index 7d50fb7..2f818d4 100644 --- a/src/main/java/de/ph87/tools/web/WebConfig.java +++ b/src/main/java/de/ph87/tools/web/WebConfig.java @@ -1,24 +1,32 @@ package de.ph87.tools.web; +import de.ph87.tools.UserArgumentResolver; import lombok.NonNull; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.resource.PathResourceResolver; import java.io.IOException; +import java.util.List; @Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { + @Override + public void addArgumentResolvers(List argumentResolvers) { + argumentResolvers.add(new UserArgumentResolver()); + } + @Override public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**").allowedOrigins("*").allowedMethods("*"); + registry.addMapping("/**").allowCredentials(true).allowedOriginPatterns("*").allowedMethods("*"); } @Override diff --git a/src/main/java/de/ph87/tools/web/WebSocketConfig.java b/src/main/java/de/ph87/tools/web/WebSocketConfig.java new file mode 100644 index 0000000..0912a3d --- /dev/null +++ b/src/main/java/de/ph87/tools/web/WebSocketConfig.java @@ -0,0 +1,35 @@ +package de.ph87.tools.web; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@CrossOrigin +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + public static final String DESTINATION = ""; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws").setAllowedOrigins("*"); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker(DESTINATION).setHeartbeatValue(new long[]{2000, 2000}).setTaskScheduler(heartBeatScheduler()); + } + + @Bean + public TaskScheduler heartBeatScheduler() { + return new ThreadPoolTaskScheduler(); + } + +} diff --git a/src/main/java/de/ph87/tools/web/WebSocketService.java b/src/main/java/de/ph87/tools/web/WebSocketService.java new file mode 100644 index 0000000..cd9e6c7 --- /dev/null +++ b/src/main/java/de/ph87/tools/web/WebSocketService.java @@ -0,0 +1,25 @@ +package de.ph87.tools.web; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.lang.NonNull; +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; + +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WebSocketService { + + private final SimpMessageSendingOperations simpMessageSendingOperations; + + @EventListener(IWebSocketMessage.class) + public void send(@NonNull final IWebSocketMessage message) { + final String topic = message.getWebsocketTopic().stream().map(Object::toString).collect(Collectors.joining("/")); + simpMessageSendingOperations.convertAndSend(topic, message); + } + +}