added tags + filtering

This commit is contained in:
_Bastler 2021-12-01 19:01:17 +01:00
parent 1da276c670
commit 979324ccc8
30 changed files with 526 additions and 84 deletions

View File

@ -49,6 +49,7 @@ import { ConfirmDialog } from './ui/confirm/confirm.component'
import { I18nService, I18nPaginatorIntl } from './services/i18n.service';
import { ServiceWorkerModule } from '@angular/service-worker';
import { environment } from '../environments/environment';
import { UiTagsPicker } from './ui/tags/tagspicker.ui';
export function fetchI18n(i18n: I18nService) {
@ -112,6 +113,7 @@ export class XhrInterceptor implements HttpInterceptor {
UiMain,
UiPoints,
ConfirmDialog,
UiTagsPicker
],
imports: [
BrowserModule,

View File

@ -1 +1 @@
<ui-entries [entries]="entries" [refresh]="boundRefresh" [update]="boundUpdate"></ui-entries>
<ui-entries [entries]="entries" [refresh]="boundRefresh" [update]="boundUpdate" [gravityFilter]="gravityFilter"></ui-entries>

View File

@ -12,10 +12,13 @@ export class PageEntries implements OnInit {
settings: any;
@Input() fetch: Function;
@Input() gravityFilter: boolean = false;
entries: any;
asc: boolean = false;
boundRefresh: Function;
boundUpdate: Function;
init: boolean = true;
filterOpen: boolean = false;
constructor(
private settingsService: SettingsService, private router: Router, private route: ActivatedRoute) { }
@ -25,7 +28,7 @@ export class PageEntries implements OnInit {
this.boundUpdate = this.update.bind(this);
this.route.queryParams.subscribe(params => {
if (this.init) {
this.entries = {};
this.entries = { filter: {} };
if (params[ 'p' ]) {
this.entries.number = +params[ 'p' ] - 1;
if (this.entries.number < 0) {
@ -37,6 +40,16 @@ export class PageEntries implements OnInit {
this.entries.size = +params[ 's' ];
}
if (params[ 'asc' ]) {
this.asc = true;
}
for (const param in params) {
if (param != 's' && param != 'p' && param != 'asc') {
this.entries.filter[ param ] = params[ param ];
}
}
this.settingsService.settings.subscribe((settings) => {
this.settings = settings;
this.refresh();
@ -51,9 +64,11 @@ export class PageEntries implements OnInit {
this.entries = {};
}
const filter = JSON.parse(JSON.stringify(this.entries.filter || {}))
this.entries.content = null;
this.fetch(this.entries.number || 0, this.entries.size || this.settings.pageSize).subscribe((data: any) => {
this.fetch(this.entries.number || 0, this.entries.size || this.settings.pageSize, this.asc, this.entries.filter).subscribe((data: any) => {
this.entries = data;
this.entries.filter = filter;
}, (error) => { })
}
@ -70,6 +85,12 @@ export class PageEntries implements OnInit {
params.s = event.pageSize;
}
if (this.entries.filter) {
for (const param in this.entries.filter) {
params[ param ] = this.entries.filter[ param ];
}
}
this.router.navigate(
[],
{
@ -78,9 +99,13 @@ export class PageEntries implements OnInit {
queryParamsHandling: 'merge'
});
this.fetch(event.pageIndex, event.pageSize).subscribe((data: any) => {
const filter = JSON.parse(JSON.stringify(this.entries.filter || {}))
this.fetch(event.pageIndex, event.pageSize, this.asc, this.entries.filter).subscribe((data: any) => {
this.entries = data;
}, (error) => { })
this.entries.filter = filter;
}, (error) => {
this.entries = {};
})
}
}

View File

@ -1,5 +1,6 @@
<div class="container">
<form [formGroup]="form" (ngSubmit)="update()" #formDirective="ngForm">
<mat-progress-bar *ngIf="!entry" mode="indeterminate"></mat-progress-bar>
<form [formGroup]="form" (ngSubmit)="update()" #formDirective="ngForm" *ngIf="entry">
<mat-card>
<mat-card-content>
<p>{{'submission.edit' | i18n}}</p>
@ -23,18 +24,21 @@
</mat-error>
</mat-form-field>
<mat-form-field>
<textarea [mat-autosize] [matAutosizeMinRows]="3" matInput placeholder="{{'submission.text' | i18n}}" [required]="entryType != 'LINK'"
formControlName="text"></textarea>
<textarea [mat-autosize] [matAutosizeMinRows]="3" matInput placeholder="{{'submission.text' | i18n}}"
[required]="entryType != 'LINK'" formControlName="text"></textarea>
<mat-error>
{{'submission.text.error' | i18n}}
</mat-error>
</mat-form-field>
<ui-tagspicker [(model)]="entry.tags" placeholder="{{'submission.tags' | i18n}}"></ui-tagspicker>
</mat-card-content>
<mat-card-actions>
<button *ngIf="!working" mat-raised-button color="primary" [disabled]="form.invalid">
{{'submission.update' | i18n}}
</button>
<a *ngIf="success" mat-button color="primary">{{'submission.success' | i18n}}</a>
<a *ngIf="success" mat-button color="primary" routerLink="/e/{{entry.id}}">{{'submission.success' | i18n}}</a>
</mat-card-actions>
</mat-card>
</form>

View File

@ -2,6 +2,11 @@ mat-form-field {
display: block;
}
mat-chip mat-icon.mat-icon-inline {
margin-top: -12px;
margin-right: -2px;
}
form {
margin: 5px;
@ -17,4 +22,4 @@ form {
@media screen and (min-width: 992px) {
max-width: 50%;
}
}
}

View File

@ -1,9 +1,12 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { EntriesService } from '../../../services/entries.service';
import { ActivatedRoute, Router } from '@angular/router';
import { ActivatedRoute } from '@angular/router';
import { FormBuilder, FormGroup, Validators, NgForm } from '@angular/forms';
import { distinctUntilChanged, debounceTime } from 'rxjs/operators';
import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
import { distinctUntilChanged, debounceTime } from 'rxjs/operators';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatChipInputEvent } from '@angular/material/chips';
import { TagsService } from 'src/app/services/tags.service';
@Component({
selector: 'page-entry-edit',
@ -20,9 +23,11 @@ export class PageEntryEdit implements OnInit {
working: boolean = false;
success: boolean = false;
form: FormGroup;
readonly tagsSeparatorKeysCodes = [ ENTER, COMMA, SPACE ] as const;
@ViewChild('formDirective') private formDirective: NgForm;
constructor(private entriesService: EntriesService,
private tagsService: TagsService,
private formBuilder: FormBuilder,
private route: ActivatedRoute,
private snackBar: MatSnackBar) { }
@ -73,6 +78,11 @@ export class PageEntryEdit implements OnInit {
this.form.get("url").setValue(this.entry.url);
this.form.get("title").setValue(this.entry.title);
this.form.get("text").setValue(this.entry.text);
if (!this.entry.metadata.edit) {
this.form.get("url").disable();
this.form.get("title").disable();
this.form.get("text").disable();
}
}, (error) => {
if (error.status == 404) {
this.notfound = true;
@ -88,6 +98,25 @@ export class PageEntryEdit implements OnInit {
}
addTag(event: MatChipInputEvent): void {
let value = (event.value || "").trim();
if (value.startsWith('#')) {
value = value.replace('#', '');
}
value = value.split('#').join('-');
if (value) {
this.entry.tags.push(value);
}
event.chipInput!.clear();
}
removeTag(tag: string): void {
const index = this.entry.tags.indexOf(tag);
if (index >= 0) {
this.entry.tags.splice(index, 1);
}
}
update(): void {
if (this.working) {
@ -96,31 +125,55 @@ export class PageEntryEdit implements OnInit {
this.working = true;
this.entry.url = this.form.get("url").value;
this.entry.title = this.form.get("title").value;
this.entry.text = this.form.get("text").value;
if (this.entry.metadata.edit) {
this.entry.url = this.form.get("url").value;
this.entry.title = this.form.get("title").value;
this.entry.text = this.form.get("text").value;
this.entriesService.update(this.entry).subscribe((data) => {
this.entry = data;
this.working = false;
this.success = true;
}, (error) => {
this.working = false;
if (error.status == 403) {
this.snackBar.open("Error");
}
if (error.status == 422) {
let errors = {};
for (let code of error.error) {
errors[ code.field ] = errors[ code.field ] || {};
errors[ code.field ][ code.code ] = true;
this.entriesService.update(this.entry).subscribe((data) => {
this.entry = data;
this.working = false;
this.success = true;
}, (error) => {
this.working = false;
if (error.status == 403) {
this.snackBar.open("Error");
}
if (error.status == 422) {
let errors = {};
for (let code of error.error) {
errors[ code.field ] = errors[ code.field ] || {};
errors[ code.field ][ code.code ] = true;
}
for (let code in errors) {
this.form.get(code).setErrors(errors[ code ]);
for (let code in errors) {
this.form.get(code).setErrors(errors[ code ]);
}
}
}
})
})
} else {
this.tagsService.setTags(this.entry.id, this.entry.tags).subscribe((data) => {
this.entry = data;
this.working = false;
this.success = true;
}, (error) => {
this.working = false;
if (error.status == 403) {
this.snackBar.open("Error");
}
if (error.status == 422) {
let errors = {};
for (let code of error.error) {
errors[ code.field ] = errors[ code.field ] || {};
errors[ code.field ][ code.code ] = true;
}
for (let code in errors) {
this.form.get(code).setErrors(errors[ code ]);
}
}
})
}
}

View File

@ -1,7 +1,7 @@
<div class="container">
<page-notfound *ngIf="notfound"></page-notfound>
<ng-container *ngIf="entry">
<ui-entry [entry]="entry" [change]="boundRefresh"></ui-entry>
<ui-entry [entry]="entry" [change]="boundRefresh" [linkedTag]="false"></ui-entry>
<p class="text" [innerHTML]="entry.text | urltext"></p>

View File

@ -33,6 +33,7 @@ export class PageEntry implements OnInit {
this.entry = data;
}, (error) => {
if (error.status == 404) {
this.entry = false;
this.notfound = true;
}
})

View File

@ -1 +1 @@
<page-entries [fetch]="boundFetch"></page-entries>
<page-entries [fetch]="boundFetch" [gravityFilter]="true"></page-entries>

View File

@ -19,8 +19,8 @@ export class PageHot implements OnInit {
this.boundFetch = this.fetch.bind(this);
}
fetch(page: number, size: number) {
return this.entriesService.getComments(page,size);
fetch(page: number, size: number, asc: boolean, filter: any) {
return this.entriesService.getComments(page, size, asc, filter);
}
}

View File

@ -16,8 +16,8 @@ export class PageLast implements OnInit {
this.boundFetch = this.fetch.bind(this);
}
fetch(page: number, size: number) {
return this.entriesService.getLastComment(page,size);
fetch(page: number, size: number,asc: boolean, filter: any) {
return this.entriesService.getLastComment(page, size,asc, filter);
}
}

View File

@ -53,7 +53,7 @@
<span fxFlexOffset="auto"></span>
<div class="container" *ngIf="!internalLogin">
<small>
<a href="/login?all">{{'login.local' | i18n}}</a>
<a href="javascript:" (click)="internalLogin = true;">{{'login.local' | i18n}}</a>
</small>
</div>
</div>

View File

@ -18,8 +18,8 @@ export class PageNew implements OnInit {
this.boundFetch = this.fetch.bind(this);
}
fetch(page: number, size: number) {
return this.entriesService.getNew(page,size);
fetch(page: number, size: number, asc: boolean, filter: any) {
return this.entriesService.getNew(page, size, asc, filter);
}
}

View File

@ -31,12 +31,15 @@
</mat-error>
</mat-form-field>
<mat-form-field>
<textarea [mat-autosize] [matAutosizeMinRows]="3" matInput placeholder="{{'submission.text' | i18n}}" [required]="entryType != 'LINK'"
formControlName="text"></textarea>
<textarea [mat-autosize] [matAutosizeMinRows]="3" matInput placeholder="{{'submission.text' | i18n}}"
[required]="entryType != 'LINK'" formControlName="text"></textarea>
<mat-error>
{{'submission.text.error' | i18n}}
</mat-error>
</mat-form-field>
<ui-tagspicker [(model)]="tags" placeholder="{{'submission.tags' | i18n}}"></ui-tagspicker>
</mat-card-content>
<mat-card-actions>
<button *ngIf="!working" mat-raised-button color="primary" [disabled]="form.invalid">

View File

@ -2,6 +2,11 @@ mat-form-field {
display: block;
}
ui-tagspicker {
display: block;
width: 100%;
}
form {
margin: 5px;
@ -17,4 +22,4 @@ form {
@media screen and (min-width: 992px) {
max-width: 50%;
}
}
}

View File

@ -2,7 +2,9 @@ import { Component, OnInit, ViewChild } from '@angular/core';
import { EntriesService } from '../../services/entries.service';
import { Router } from '@angular/router';
import { FormBuilder, FormGroup, Validators, NgForm } from '@angular/forms';
import { tap, distinctUntilChanged, debounceTime } from 'rxjs/operators';
import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
import { distinctUntilChanged, debounceTime } from 'rxjs/operators';
import { MatChipInputEvent } from '@angular/material/chips';
@Component({
selector: 'page-submission',
@ -15,6 +17,8 @@ export class PageSubmission implements OnInit {
entryType: string = this.entryTypes[ 0 ];
working: boolean = false;
form: FormGroup;
readonly tagsSeparatorKeysCodes = [ ENTER, COMMA, SPACE ] as const;
tags: string[] = [];
@ViewChild('formDirective') private formDirective: NgForm;
constructor(private entriesService: EntriesService,
@ -64,6 +68,25 @@ export class PageSubmission implements OnInit {
}
addTag(event: MatChipInputEvent): void {
let value = (event.value || "").trim();
if (value.startsWith('#')) {
value = value.replace('#', '');
}
value = value.split('#').join('-');
if (value) {
this.tags.push(value);
}
event.chipInput!.clear();
}
removeTag(tag: string): void {
const index = this.tags.indexOf(tag);
if (index >= 0) {
this.tags.splice(index, 1);
}
}
create(): void {
if (this.working) {
@ -77,6 +100,7 @@ export class PageSubmission implements OnInit {
entry.entryType = this.entryType;
entry.title = this.form.get("title").value;
entry.text = this.form.get("text").value;
entry.tags = this.tags;
this.entriesService.create(entry).subscribe((data) => {
this.router.navigateByUrl('/');

View File

@ -1 +1 @@
<page-entries [fetch]="boundFetch"></page-entries>
<page-entries [fetch]="boundFetch" [gravityFilter]="true"></page-entries>

View File

@ -16,8 +16,8 @@ export class PageTop implements OnInit {
this.boundFetch = this.fetch.bind(this);
}
fetch(page: number, size: number) {
return this.entriesService.getRanked(page,size);
fetch(page: number, size: number, asc: boolean, filter: any) {
return this.entriesService.getRanked(page, size, asc, filter);
}
}

View File

@ -20,8 +20,8 @@ export class PageUserEntries implements OnInit {
this.boundFetch = this.fetch.bind(this);
}
fetch(page: number, size: number) {
return this.entriesService.getByUser(this.username, page, size);
fetch(page: number, size: number, asc: boolean, filter: any) {
return this.entriesService.getByUser(this.username, page, size, asc, filter);
}
}

View File

@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../environments/environment';
@Injectable({
@ -10,24 +10,48 @@ export class EntriesService {
constructor(private http: HttpClient) {
}
getRanked(page: number, size: number) {
return this.http.get(environment.apiUrl + "/entries?page=" + page + "&size=" + size);
fetch(path: string, page: number, size: number, asc: boolean, filter: any) {
let httpParams = new HttpParams();
if (page != undefined) {
httpParams = httpParams.set("page", "" + page);
}
if (size != undefined) {
httpParams = httpParams.set("size", "" + size);
}
if (asc) {
httpParams = httpParams.set("asc", "" + asc);
}
if (filter) {
for (const param in filter) {
if (filter[ param ]) {
httpParams = httpParams.set(param, "" + filter[ param ]);
}
}
}
return this.http.get(environment.apiUrl + "/entries" + path, { params: httpParams });
}
getNew(page: number, size: number) {
return this.http.get(environment.apiUrl + "/entries/new?page=" + page + "&size=" + size);
getRanked(page: number, size: number, asc: boolean, filter: any) {
return this.fetch("", page, size, asc, filter);
}
getComments(page: number, size: number) {
return this.http.get(environment.apiUrl + "/entries/comments?page=" + page + "&size=" + size);
getNew(page: number, size: number, asc: boolean, filter: any) {
return this.fetch("/new", page, size, asc, filter);
}
getLastComment(page: number, size: number) {
return this.http.get(environment.apiUrl + "/entries/last?page=" + page + "&size=" + size);
getComments(page: number, size: number, asc: boolean, filter: any) {
return this.fetch("/comments", page, size, asc, filter);
}
getByUser(username: string, page: number, size: number) {
return this.http.get(environment.apiUrl + "/entries/byuser/" + username + "?page=" + page + "&size=" + size);
getLastComment(page: number, size: number, asc: boolean, filter: any) {
return this.fetch("/last", page, size, asc, filter);
}
getByUser(username: string, page: number, size: number, asc: boolean, filter: any) {
return this.fetch("/byuser/" + username, page, size, asc, filter);
}
getEntry(id: number) {

View File

@ -0,0 +1,22 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../environments/environment';
@Injectable({
providedIn: 'root',
})
export class TagsService {
constructor(private http: HttpClient) {
}
search(query: string) {
return this.http.get(environment.apiUrl + "/tags?q=" + query);
}
setTags(id: number, tags: string[]) {
return this.http.patch(environment.apiUrl + "/tags/entry/" + id, tags);
}
}

View File

@ -1,7 +1,7 @@
<mat-progress-bar *ngIf="!entries || !entries.content" mode="indeterminate"></mat-progress-bar>
<div *ngIf="entries && entries.content" fxLayout="column" fxFlexFill>
<mat-list>
<div *ngIf="entries" fxLayout="column" fxFlexFill>
<mat-list *ngIf="entries.content">
<ng-container *ngFor="let entry of entries.content; let i = index">
<mat-divider *ngIf="i > 0"></mat-divider>
<mat-list-item class="entry-item">
@ -19,7 +19,33 @@
<span fxFlexOffset="auto"></span>
<mat-paginator *ngIf="entries.totalElements > 0" [pageSizeOptions]="pageSizeOptions" [pageIndex]="entries.number"
[length]="entries.totalElements" [pageSize]="entries.size" (page)="update && update($event)" showFirstLastButtons>
</mat-paginator>
<div class="mat-paginator" fxLayout="row" fxLayout.xs="column" fxLayoutAlign.xs="start start">
<div class="filter-container">
<a mat-icon-button mat-button (click)="filterOpen=!filterOpen" title="{{'entries.filter' | i18n}}">
<mat-icon>filter_alt</mat-icon>
</a>
<div *ngIf="filterOpen">
<ui-tagspicker [(model)]="entries.filter.tag" singleton="true" placeholder="{{'entries.filter.tag' | i18n}}" [change]="boundTagspickerChange"></ui-tagspicker>
<mat-form-field>
<input matInput [matDatepicker]="picker" [value]="entries && entries.filter && entries.filter.date"
(dateChange)="setFilter('date', $event.value && $event.value.toISOString() || undefined)"
placeholder="{{'entries.filter.date' | i18n}}">
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker touchUi></mat-datepicker>
</mat-form-field>
<mat-form-field *ngIf="gravityFilter">
<input matInput type="number" step="0.01" min="0"
[value]="entries && entries.filter && entries.filter.gravity"
(change)="setFilter('gravity', $event.target && $event.target.value || undefined)"
placeholder="{{'entries.filter.gravity' | i18n}}">
</mat-form-field>
</div>
</div>
<span fxFlexOffset="auto" fxFlexOffset.xs="none"></span>
<mat-paginator [pageSizeOptions]="pageSizeOptions" [pageIndex]="entries.number" [length]="entries.totalElements"
[pageSize]="entries.size" (page)="update && update($event)" showFirstLastButtons>
</mat-paginator>
</div>
</div>

View File

@ -1,9 +1,30 @@
.entry {
display: inline-block;
width: 100%;
max-width: 100%;
}
.entry-item {
height: auto;
min-height: 48px;
}
.filter-container {
padding-left: 15px;
display: flex;
justify-content: center;
align-items: center;
mat-form-field {
margin-left: 15px;
}
}
mat-chip mat-icon.mat-icon-inline {
margin-top: -12px;
margin-right: -2px;
}
.mat-option .mat-icon {
margin-right: -2px;
}

View File

@ -1,4 +1,10 @@
import { Component, OnInit, Input } from '@angular/core';
import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
import { Component, OnInit, Input, ViewChild } from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatPaginator } from '@angular/material/paginator';
import { Observable } from 'rxjs';
import { debounceTime, switchMap } from 'rxjs/operators';
import { TagsService } from 'src/app/services/tags.service';
@Component({
selector: 'ui-entries',
@ -7,19 +13,55 @@ import { Component, OnInit, Input } from '@angular/core';
})
export class UiEntries implements OnInit {
@Input() entries: any;
@Input() update: Function;
@Input() refresh: Function;
@Input() gravityFilter: boolean = false;
@ViewChild(MatPaginator) matPaginator;
pageSizeOptions: number[] = [ 1, 2, 3, 4, 5, 10, 30, 50, 100 ];
filterOpen: boolean = false;
searchTags: Observable<Object>;
boundTagspickerChange: Function;
constructor() { }
searchFormControl = new FormControl();
readonly separatorKeysCodes = [ ENTER, COMMA, SPACE ] as const;
constructor(private tagsService: TagsService) { }
ngOnInit(): void {
this.checkFilterOpen();
this.boundTagspickerChange = this.tagspickerChange.bind(this);
this.searchTags = this.searchFormControl
.valueChanges
.pipe(
debounceTime(300),
switchMap(value => this.tagsService.search(value))
);
}
checkFilterOpen() {
this.filterOpen = false;
if (this.entries.filter) {
for (const param in this.entries.filter) {
if (this.entries.filter[ param ]) {
this.filterOpen = true;
break;
}
}
}
}
tagspickerChange(value: any) {
console.log("change", value);
this.setFilter('tag', value);
}
setFilter(key: string, value) {
if (value != this.entries.filter[ key ]) {
this.entries.filter[ key ] = value;
this.entries.number = 0;
this.update({ pageIndex: this.entries.number, pageSize: this.entries.size, length: this.entries.totalElements });
}
}
}

View File

@ -5,6 +5,9 @@
<a class="title" *ngIf="entry.url" [href]="entry.url" target="_blank">{{entry.title}}</a>
<a class="title" *ngIf="!entry.url" routerLink="/e/{{entry.id}}">{{entry.title}}</a>
<span *ngIf="entry.url" class="urlbase">(<a [href]="entry.url">{{entry.url | urlbase}}</a>)</span>
<a *ngFor="let tag of entry.tags" class="tag" (click)="applyTag(tag)">#{{tag}}</a>
<a *ngIf="entry.metadata.author && !entry.metadata.edit" class="tags-edit tag"
routerLink="/e/{{entry.id}}/edit"><mat-icon inline="true">local_offer</mat-icon>{{'entry.edit.tags' | i18n}}</a>
</div>
<div mat-line>
<small>
@ -31,8 +34,8 @@
<span> | </span>
<a routerLink="/e/{{entry.id}}">{{(entry.metadata && entry.metadata.comments == 1 ? 'entry.comment' :
'entry.comments') | i18n:(entry.metadata && entry.metadata.comments)}}</a>
<span *ngIf="canEdit()"> | </span>
<a *ngIf="canEdit()" routerLink="/e/{{entry.id}}/edit">{{'entry.edit' | i18n}}</a>
<span *ngIf="entry.metadata.edit"> | </span>
<a *ngIf="entry.metadata.edit" routerLink="/e/{{entry.id}}/edit">{{'entry.edit' | i18n}}</a>
<span *ngIf="entry.metadata && entry.metadata.bookmark"> | </span>
<a *ngIf="entry.metadata && entry.metadata.bookmark" href="javascript:" (click)="addBookmark()"
matTooltip="{{'bookmarks.add' | i18n}}">
@ -56,8 +59,8 @@
i18n}}">
<mat-icon inline="true">flag</mat-icon>
</a>
<span *ngIf="canEdit()"> | </span>
<a *ngIf="canEdit()" href="javascript:" (click)="deleteEntry()">{{'entry.delete' | i18n}}</a>
<span *ngIf="entry.metadata.edit"> | </span>
<a *ngIf="entry.metadata.edit" href="javascript:" (click)="deleteEntry()">{{'entry.delete' | i18n}}</a>
<span *ngIf="moderator" class="mod">
<span *ngIf="entry.metadata.flagged"> | </span>
<a *ngIf="entry.metadata.flagged" href="javascript:" (click)="modUnflagEntry()">{{'moderation.entry.unflag' |

View File

@ -52,3 +52,48 @@ span.mod {
font-style: italic;
opacity: 0.7;
}
.tag {
cursor: default;
position: relative;
font-size: 9px;
background-color: #616161;
color: white;
border-radius: 16px;
padding: 2px 5px;
margin-left: 3px;
top: -1px;
.mat-icon {
top: 1px;
}
}
.tag::after {
background-color: #000;
top: 0;
left: 0;
right: 0;
bottom: 0;
position: absolute;
border-radius: inherit;
opacity: 0;
content: "";
pointer-events: none;
transition: opacity 200ms cubic-bezier(0.35, 0, 0.25, 1);
}
.tag:hover::after {
opacity: .12;
}
.tags-edit.tag {
text-decoration: none;
cursor: pointer;
opacity: 0.2;
}
.tags-edit:hover.tag {
opacity: 0.7;
}

View File

@ -8,6 +8,7 @@ import { BookmarksService } from '../../services/bookmarks.service';
import { ModerationService } from '../../services/moderarion.service';
import { ConfirmDialog } from '../../ui/confirm/confirm.component';
import { EntriesService } from 'src/app/services/entries.service';
import { ActivatedRoute, Params, Router } from '@angular/router';
@Component({
selector: 'ui-entry',
@ -16,19 +17,18 @@ import { EntriesService } from 'src/app/services/entries.service';
})
export class UiEntry implements OnInit {
author: boolean = false;
moderator: boolean = false;
@Input() entry: any;
@Input() index: number;
@Input() linkedTag: boolean = true;
@Input() change: Function;
constructor(private authService: AuthService, private entriesService: EntriesService, private voteService: VoteService, private flagService: FlagService,
private moderationService: ModerationService, private bookmarksService: BookmarksService, public dialog: MatDialog) { }
private moderationService: ModerationService, private bookmarksService: BookmarksService, public dialog: MatDialog, private router: Router, private route: ActivatedRoute) { }
ngOnInit(): void {
this.authService.auth.subscribe((auth: any) => {
if (auth && auth.authorities) {
this.author = auth.username == this.entry.author;
for (let role of auth.authorities) {
if (role.authority == 'ROLE_ADMIN' || role.authority == 'ROLE_MOD') {
this.moderator = true;
@ -38,6 +38,19 @@ export class UiEntry implements OnInit {
})
}
applyTag(tag) {
if (this.linkedTag) {
this.router.routeReuseStrategy.shouldReuseRoute = () => false;
this.router.navigate(
[],
{
relativeTo: this.route,
queryParams: { 'tag': tag },
queryParamsHandling: 'merge'
});
}
}
voteUp() {
this.voteService.voteEntryUp(this.entry.id).subscribe((result) => {
this.change && this.change();
@ -84,11 +97,6 @@ export class UiEntry implements OnInit {
});
}
canEdit(): boolean {
const canEdit = this.author && (new Date(this.entry.created).getTime() > new Date().getTime());
return canEdit;
}
deleteEntry() {
const dialogRef = this.dialog.open(ConfirmDialog, {
data: {

View File

@ -0,0 +1,18 @@
<mat-form-field [ngClass]="singleton ? 'singleton' : ''">
<mat-chip-list #tagList>
<mat-chip *ngFor="let tag of tags" [removable]="true" (removed)="removeTag(tag)">
<mat-icon inline="true">tag</mat-icon>{{tag}}
<button matChipRemove>
<mat-icon>cancel</mat-icon>
</button>
</mat-chip>
<input *ngIf="!singleton || !tags || tags.length < 1" #tagsInput placeholder="{{placeholder}}"
[formControl]="searchFormControl" [matAutocomplete]="auto" [matChipInputFor]="tagList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes" (matChipInputTokenEnd)="addInputTag($event)">
</mat-chip-list>
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="addOptionTag($event)">
<mat-option *ngFor="let option of searchTags | async" [value]="option">
<mat-icon inline="true">tag</mat-icon>{{option}}
</mat-option>
</mat-autocomplete>
</mat-form-field>

View File

@ -0,0 +1,16 @@
mat-form-field {
display: block;
}
mat-form-field.singleton {
display: inline-block;
}
mat-chip mat-icon.mat-icon-inline {
margin-top: -12px;
margin-right: -2px;
}
.mat-option .mat-icon {
margin-right: -2px;
}

View File

@ -0,0 +1,95 @@
import { COMMA, ENTER, SPACE } from '@angular/cdk/keycodes';
import { Component, OnInit, Input, ViewChild, ElementRef } from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { MatPaginator } from '@angular/material/paginator';
import { Observable } from 'rxjs';
import { debounceTime, switchMap } from 'rxjs/operators';
import { TagsService } from 'src/app/services/tags.service';
@Component({
selector: 'ui-tagspicker',
templateUrl: './tagspicker.ui.html',
styleUrls: [ './tagspicker.ui.scss' ]
})
export class UiTagsPicker implements OnInit {
@Input() change: Function;
@Input() model: any;
@Input() placeholder: string;
@Input() singleton: boolean = false;
tags: string[] = [];
searchTags: Observable<Object>;
@ViewChild('tagsInput') tagsInput: ElementRef<HTMLInputElement>;
searchFormControl = new FormControl();
readonly separatorKeysCodes = [ ENTER, COMMA, SPACE ] as const;
constructor(private tagsService: TagsService) { }
ngOnInit(): void {
this.searchTags = this.searchFormControl
.valueChanges
.pipe(
debounceTime(300),
switchMap(value => this.tagsService.search(value))
);
if (this.singleton) {
if (this.model) {
this.tags = [ this.model ];
}
} else {
this.tags = this.model || [];
}
}
addTag(tag: string) {
if (tag.startsWith('#')) {
tag = tag.replace('#', '');
}
tag = tag.split('#').join('-');
if (tag && this.tags.indexOf(tag) == -1) {
this.tags.push(tag);
}
if (this.singleton) {
this.model = this.tags && this.tags[ 0 ] || undefined;
} else {
this.model = this.tags;
}
if (this.change) {
this.change(this.model);
}
this.tagsInput.nativeElement.value = '';
}
addInputTag(event: MatChipInputEvent): void {
this.addTag((event.value || "").trim())
event.chipInput!.clear();
}
addOptionTag(event: MatAutocompleteSelectedEvent): void {
this.addTag((event.option && event.option.value || "").trim());
}
removeTag(tag: string): void {
const index = this.tags.indexOf(tag);
if (index >= 0) {
this.tags.splice(index, 1);
}
if (this.singleton) {
this.model = undefined;
} else {
this.model = this.tags;
}
if (this.change) {
this.change(this.model);
}
}
}