feat: add error popups and likes

This commit is contained in:
TopchetoEU 2023-06-30 23:01:08 +03:00
parent ba41d147da
commit d080822781
22 changed files with 302 additions and 63 deletions

View File

@ -5,12 +5,12 @@ import { JWTPayload } from "../utils/JWT.ts";
import AppRouter from "./AppRouter.ts"; import AppRouter from "./AppRouter.ts";
import HttpError from "../server/HttpError.ts"; import HttpError from "../server/HttpError.ts";
import Image, { Visibility } from "../models/Image.ts"; import Image, { Visibility } from "../models/Image.ts";
import uuid, { Page } from "../server/decorators/page.ts";
import { Headers } from "../server/RestRequest.ts"; import { Headers } from "../server/RestRequest.ts";
import { convert } from "../server/decorators/schema.ts"; import { convert } from "../server/decorators/schema.ts";
import { now } from "../utils/utils.ts"; import { now } from "../utils/utils.ts";
import AppDatabase from "../AppDatabase.ts"; import AppDatabase from "../AppDatabase.ts";
import RestResponse from "../server/RestResponse.ts"; import RestResponse from "../server/RestResponse.ts";
import { Page } from "../server/decorators/page.ts";
export default class ImageRouter extends AppRouter { export default class ImageRouter extends AppRouter {
public static deserialize(image: Image, me?: string) { public static deserialize(image: Image, me?: string) {
@ -138,7 +138,7 @@ export default class ImageRouter extends AppRouter {
} }
} }
@rest('POST', '/change') @rest('POST', '/change')
async change(@uuid() id: UUID, @body() raw: unknown, @jwt(v => v.salt, true) @auth() jwt: JWTPayload) { async change(@schema('uuid') id: UUID, @body() raw: unknown, @jwt(v => v.salt, true) @auth() jwt: JWTPayload) {
const body = await convert(raw, { name: 'string?', visibility: 'number?' }); const body = await convert(raw, { name: 'string?', visibility: 'number?' });
const user = await this.db.users.findOne({ username: jwt.name }); const user = await this.db.users.findOne({ username: jwt.name });
if (!user) throw new HttpError("You don't exist."); if (!user) throw new HttpError("You don't exist.");
@ -152,7 +152,7 @@ export default class ImageRouter extends AppRouter {
} }
@rest('POST', '/like') @rest('POST', '/like')
async like(@uuid() id: UUID, @jwt(v => v.salt, true) @auth() jwt: JWTPayload) { async like(@schema('uuid') id: UUID, @jwt(v => v.salt, true) @auth() jwt: JWTPayload) {
const user = await this.db.users.findOne({ username: jwt.name }); const user = await this.db.users.findOne({ username: jwt.name });
if (!user) throw new HttpError("You don't exist."); if (!user) throw new HttpError("You don't exist.");
@ -167,10 +167,10 @@ export default class ImageRouter extends AppRouter {
); );
return ImageRouter.deserialize((await this.db.images.findOne({ _id: id }))!); return ImageRouter.deserialize((await this.db.images.findOne({ _id: id }))!, jwt?.name);
} }
@rest('POST', '/dislike') @rest('POST', '/unlike')
async dislike(@uuid() id: UUID, @jwt(v => v.salt, true) @auth() jwt: JWTPayload) { async unlike(@schema('uuid') id: UUID, @jwt(v => v.salt, true) @auth() jwt: JWTPayload) {
const user = await this.db.users.findOne({ username: jwt.name }); const user = await this.db.users.findOne({ username: jwt.name });
if (!user) throw new HttpError("You don't exist."); if (!user) throw new HttpError("You don't exist.");

View File

@ -24,7 +24,7 @@ export class Page {
} }
} }
export default function uuid() { export default function page() {
return makeParameterModifier(req => { return makeParameterModifier(req => {
let n: number | undefined = Number.parseInt(req.params.n); let n: number | undefined = Number.parseInt(req.params.n);
let i: number | undefined = Number.parseInt(req.params.i); let i: number | undefined = Number.parseInt(req.params.i);

View File

@ -15,3 +15,9 @@
<div class="content" #container> <div class="content" #container>
<router-outlet #o="outlet"></router-outlet> <router-outlet #o="outlet"></router-outlet>
</div> </div>
<div class="overlay">
<div class="messages-container">
<app-message class="msg" (despawned)="despawnMsg(msg.id)" [isDespawning]="true" [message]="msg" *ngFor="let msg of removedMessages"></app-message>
<app-message class="msg" [message]="msg" *ngFor="let msg of messages"></app-message>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'; import { AfterViewInit, Component, ElementRef, NgZone, ViewChild } from '@angular/core';
import { UsersService } from './services/users.service'; import { UsersService } from './services/users.service';
import { ScrollService } from './services/scroll.service'; import { ScrollService } from './services/scroll.service';
import { Message, MessagesService } from './services/messages.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -10,12 +11,31 @@ import { ScrollService } from './services/scroll.service';
export class AppComponent implements AfterViewInit { export class AppComponent implements AfterViewInit {
@ViewChild('container') container!: ElementRef<HTMLDivElement>; @ViewChild('container') container!: ElementRef<HTMLDivElement>;
public messages: Message[] = [];
public removedMessages: Message[] = [];
public ngAfterViewInit(): void { public ngAfterViewInit(): void {
this.scroll.scrollHost = this.container.nativeElement; this.scroll.scrollHost = this.container.nativeElement;
} }
public despawnMsg(id: number) {
this.zone.run(() => {
this.removedMessages = this.removedMessages.filter(v => v.id !== id);
});
}
constructor( constructor(
public users: UsersService, public users: UsersService,
public scroll: ScrollService public scroll: ScrollService,
) { } public zone: NgZone,
public _messages: MessagesService,
) {
_messages.added.subscribe(v => {
this.messages.push(v);
});
_messages.removed.subscribe(v => {
this.messages.splice(this.messages.indexOf(v), 1);
this.removedMessages.push(v);
});
}
} }

View File

@ -12,6 +12,7 @@ import { PageUploadComponent } from './page-upload/page-upload.component';
import { PageUserComponent } from './page-user/page-user.component'; import { PageUserComponent } from './page-user/page-user.component';
import { ImagesComponent } from './images/images.component'; import { ImagesComponent } from './images/images.component';
import { ImageComponent } from './image/image.component'; import { ImageComponent } from './image/image.component';
import { MessageComponent } from './message/message.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -22,7 +23,8 @@ import { ImageComponent } from './image/image.component';
PageUploadComponent, PageUploadComponent,
PageUserComponent, PageUserComponent,
ImagesComponent, ImagesComponent,
ImageComponent ImageComponent,
MessageComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@ -1 +1,10 @@
<p>image works!</p> <img class="image" [src]="environment.apiURL + '/images/img/' + image.file" *ngIf="image">
<h3 class="name" *ngIf="image">{{image.name}}</h3>
<div class="stats" *ngIf="image">
<span class="likes">
<img class="like" src="assets/like-active.svg" *ngIf="image.liked" (click)="unlike()">
<img class="like" src="assets/like-inactive.svg" *ngIf="!image.liked" (click)="like()">
{{image.likes}}
</span>
<span class="author">By <a [routerLink]="'/user/' + image.author">{{image.author}}</a></span>
</div>

View File

@ -0,0 +1,40 @@
:host {
user-select: none;
.image {
pointer-events: none;
height: 15rem;
display: block;
}
width: min-content;
border-radius: 1rem;
box-shadow: .25rem .25rem 1rem -.5rem #000;
padding: 1rem;
box-sizing: border-box;
display: inline-flex;
flex-direction: column;
gap: 1rem;
margin: 1rem;
.name, .author {
text-align: left;
padding: 0;
margin: 0;
}
.stats {
display: flex;
justify-content: space-between;
.likes {
// flex: 1 0 auto;
.like {
height: 1.5rem;
}
display: inline-flex;
align-items: center;
gap: .5rem;
}
}
}

View File

@ -1,4 +1,6 @@
import { Component } from '@angular/core'; import { Component, Input } from '@angular/core';
import { environment } from 'src/environments/environment';
import { Image, ImagesService } from '../services/images.service';
@Component({ @Component({
selector: 'app-image', selector: 'app-image',
@ -6,5 +8,16 @@ import { Component } from '@angular/core';
styleUrls: ['./image.component.scss'] styleUrls: ['./image.component.scss']
}) })
export class ImageComponent { export class ImageComponent {
@Input()
public image?: Image | null;
public environment = environment;
public async like() {
this.image = await this.images.like(this.image!.id);
}
public async unlike() {
this.image = await this.images.unlike(this.image!.id);
}
public constructor(public images: ImagesService) {}
} }

View File

@ -1,9 +1,10 @@
<div class="images"> <div class="images">
<div class="image" *ngFor="let img of images"> <!-- <div class="image" *ngFor="let img of images">
<img [src]="environment.apiURL + '/images/img/' + img.file"> <img [src]="environment.apiURL + '/images/img/' + img.file">
<h3 class="image-name">{{img.name}}</h3> <h3 class="image-name">{{img.name}}</h3>
<h5 class="image-author">By <a [routerLink]="'/user/' + img.author">{{img.author}}</a></h5> <h5 class="image-author">By <a [routerLink]="'/user/' + img.author">{{img.author}}</a></h5>
</div> </div> -->
<app-image [image]="img" *ngFor="let img of images"></app-image>
</div> </div>
<h3 *ngIf="ended">No more photos :(</h3> <h3 *ngIf="ended">No more photos :(</h3>

View File

@ -8,38 +8,6 @@
box-sizing: border-box; box-sizing: border-box;
padding: 0 1rem; padding: 0 1rem;
text-align: center; text-align: center;
.image {
user-select: none;
img {
pointer-events: none;
height: 15rem;
display: block;
}
width: min-content;
border-radius: 1rem;
box-shadow: .25rem .25rem 1rem -.5rem #000;
padding: 1rem;
box-sizing: border-box;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin: 1rem;
p, h3, h5 {
padding: 0;
margin: 0;
}
h3, h5 {
text-align: center;
}
p {
text-align: justify;
}
}
} }
@media screen and (width < 50rem) { @media screen and (width < 50rem) {
.images { .images {

View File

@ -41,7 +41,6 @@ export class ImagesComponent {
@Input() public images: Image[] | null = null; @Input() public images: Image[] | null = null;
public ended = false; public ended = false;
public environment = environment;
public i = 0; public i = 0;
public constructor() { public constructor() {

View File

@ -0,0 +1,5 @@
<div class="card" *ngIf="!hasDied" [ngClass]="['container', message.role]">
<div class="content">
{{message.content}}
</div>
</div>

View File

@ -0,0 +1,30 @@
.container {
padding: .5rem 1rem;
margin: .5rem;
border-bottom-right-radius: 0;
border-top-right-radius: 0;
border-right-width: 3px;
border-right-style: solid;
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
width: 20rem;
}
.error {
border-right-color: red;
}
.warn {
border-right-color: orange;
}
.success {
border-right-color: green;
}
.info {
border-right-color: lightskyblue;
}

View File

@ -0,0 +1,57 @@
import { Component, ElementRef, EventEmitter, Input, Output } from '@angular/core';
import { Message } from '../services/messages.service';
@Component({
selector: 'app-message',
templateUrl: './message.component.html',
styleUrls: ['./message.component.scss']
})
export class MessageComponent {
private _despawning: boolean = false;
private _currAnimation?: Animation;
private _died: boolean = false;
public get isDespawning() {
return this._despawning;
}
public get hasDied() {
return this._died;
}
@Input('message')
public message!: Message;
@Input('isDespawning')
public set isDespawning(val) {
this._despawning = val;
if (val) {
this._currAnimation = this.ref.nativeElement.animate([
{
opacity: 1
},
{
opacity: 0
}
], {
easing: 'ease-out',
duration: 500,
});
this._currAnimation!.onfinish = () => {
this._died = true;
this.ref.nativeElement.style.display = 'none';
this.despawned.next();
};
}
else {
this._currAnimation?.cancel();
}
}
@Output('despawned')
public despawned = new EventEmitter<void>();
public constructor(
private ref: ElementRef
) { }
}

View File

@ -2,6 +2,7 @@ import { Component } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms'; import { FormControl, FormGroup } from '@angular/forms';
import { UsersService } from '../services/users.service'; import { UsersService } from '../services/users.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MessagesService } from '../services/messages.service';
@Component({ @Component({
selector: 'app-page-login', selector: 'app-page-login',
@ -15,11 +16,15 @@ export class PageLoginComponent {
}); });
public async login() { public async login() {
try {
await this.users.login(this.form.value.username ?? '', this.form.value.password ?? ''); await this.users.login(this.form.value.username ?? '', this.form.value.password ?? '');
this.router.navigateByUrl('/'); this.router.navigateByUrl('/');
} }
catch (e: any) { this.msgs.error(e); }
}
public constructor( public constructor(
private msgs: MessagesService,
private users: UsersService, private users: UsersService,
private router: Router private router: Router
) { } ) { }

View File

@ -2,6 +2,7 @@ import { Component } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms'; import { FormControl, FormGroup } from '@angular/forms';
import { UsersService } from '../services/users.service'; import { UsersService } from '../services/users.service';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MessagesService } from '../services/messages.service';
@Component({ @Component({
selector: 'app-page-signup', selector: 'app-page-signup',
@ -15,14 +16,18 @@ export class PageSignupComponent {
}); });
public async signup() { public async signup() {
try {
await this.users.signup( await this.users.signup(
this.form.value.username ?? '', this.form.value.username ?? '',
this.form.value.password ?? '' this.form.value.password ?? ''
); );
this.router.navigateByUrl('/'); this.router.navigateByUrl('/');
} }
catch (e: any) { this.msgs.error(e); }
}
public constructor( public constructor(
private msgs: MessagesService,
private users: UsersService, private users: UsersService,
private router: Router private router: Router
) { } ) { }

View File

@ -2,6 +2,7 @@ import { Component } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms'; import { FormControl, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { ImagesService, Visibility } from '../services/images.service'; import { ImagesService, Visibility } from '../services/images.service';
import { MessagesService } from '../services/messages.service';
@Component({ @Component({
selector: 'app-page-upload', selector: 'app-page-upload',
@ -20,15 +21,19 @@ export class PageUploadComponent {
} }
public async submit() { public async submit() {
try {
await this.images.upload({ await this.images.upload({
name: this.form.value.name ?? undefined, name: this.form.value.name ?? undefined,
visibility: this.form.value.visibility ?? undefined, visibility: this.form.value.visibility ?? undefined,
}, this.form.value.file!); }, this.form.value.file!);
this.router.navigateByUrl('/'); this.router.navigateByUrl('/');
} }
catch (e: any) { this.msgs.error(e); }
}
public constructor( public constructor(
private images: ImagesService, private images: ImagesService,
private router: Router private router: Router,
private msgs: MessagesService,
) {} ) {}
} }

View File

@ -5,6 +5,8 @@ import { Image, ImagesService } from '../services/images.service';
import { ImagesComponent } from '../images/images.component'; import { ImagesComponent } from '../images/images.component';
import { ScrollService } from '../services/scroll.service'; import { ScrollService } from '../services/scroll.service';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { MessagesService } from '../services/messages.service';
import { HttpErrorResponse } from '@angular/common/http';
@Component({ @Component({
selector: 'app-page-user', selector: 'app-page-user',
@ -26,10 +28,14 @@ export class PageUserComponent {
users: UsersService, users: UsersService,
private _images: ImagesService, private _images: ImagesService,
private _scroll: ScrollService, private _scroll: ScrollService,
private msgs: MessagesService,
) { ) {
route.paramMap.subscribe(async v => { route.paramMap.subscribe(async v => {
try {
this.user = await users.get(v.get('name')!); this.user = await users.get(v.get('name')!);
this.images = ImagesComponent.fromFeed(this._scroll.scrollHost!, 10, this.feed.bind(this)); this.images = ImagesComponent.fromFeed(this._scroll.scrollHost!, 10, this.feed.bind(this));
}
catch (e: any) { msgs.error(e); }
// this.imagesEl.next(); // this.imagesEl.next();
}); });
} }

View File

@ -16,6 +16,8 @@ export interface Image {
visibility: Visibility; visibility: Visibility;
created: number; created: number;
file: string; file: string;
likes: number;
liked?: boolean;
} }
export interface CreateImageBody { export interface CreateImageBody {
name?: string; name?: string;
@ -56,6 +58,13 @@ export class ImagesService {
return await firstValueFrom(this.http.post<string>(`${this.url}/change?id=${id}`, proto, this.users.httpOptions({}))); return await firstValueFrom(this.http.post<string>(`${this.url}/change?id=${id}`, proto, this.users.httpOptions({})));
} }
public async like(id: string) {
return await firstValueFrom(this.http.post<Image>(`${this.url}/like?id=${id}`, {}, this.users.httpOptions({})));
}
public async unlike(id: string) {
return await firstValueFrom(this.http.post<Image>(`${this.url}/unlike?id=${id}`, {}, this.users.httpOptions({})));
}
public constructor(private http: HttpClient, private users: UsersService) { public constructor(private http: HttpClient, private users: UsersService) {
} }
} }

View File

@ -0,0 +1,57 @@
import { HttpErrorResponse } from '@angular/common/http';
import { EventEmitter, Injectable } from '@angular/core';
export type Role = 'info' | 'success' | 'warning' | 'error';
export interface Message {
content: string;
role: string;
timestamp: number;
id: number;
}
@Injectable({
providedIn: 'root'
})
export class MessagesService {
private _messages: Message[] = [];
constructor() { }
public get messages() {
return this._messages;
}
public added = new EventEmitter<Message>();
public removed = new EventEmitter<Message>();
public add(message: string, role: Role) {
const id = Math.random();
const msg = {
content: message,
role: role,
timestamp: Date.now(),
id,
} as Message;
this._messages.push(msg);
if (msg.role === 'error') console.error('ERROR:', msg.content);
else console.log(msg.role, msg.content);
const timeout = setTimeout(() => {
this.removed.next(this._messages.find(v => v.id === id)!);
this._messages = this._messages.filter(v => v.id !== id);
clearTimeout(timeout);
}, 2500);
this.added.next(msg);
}
public error = (message: string | Error) => {
if (message instanceof HttpErrorResponse) this.add(message.error.error, 'error');
else if (message instanceof Error) this.add(message.message, 'error');
else this.add(message, 'error');
}
public warn = (message: string) => this.add(message, 'warning');
public success = (message: string) => this.add(message, 'success');
public info = (message: string) => this.add(message, 'info');
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 4.435c-1.989-5.399-12-4.597-12 3.568 0 4.068 3.06 9.481 12 14.997 8.94-5.516 12-10.929 12-14.997 0-8.118-10-8.999-12-3.568z"/></svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 9.229c.234-1.12 1.547-6.229 5.382-6.229 2.22 0 4.618 1.551 4.618 5.003 0 3.907-3.627 8.47-10 12.629-6.373-4.159-10-8.722-10-12.629 0-3.484 2.369-5.005 4.577-5.005 3.923 0 5.145 5.126 5.423 6.231zm-12-1.226c0 4.068 3.06 9.481 12 14.997 8.94-5.516 12-10.929 12-14.997 0-7.962-9.648-9.028-12-3.737-2.338-5.262-12-4.27-12 3.737z"/></svg>

After

Width:  |  Height:  |  Size: 429 B