Web Links (#1983)
* Updated dependencies * Updated the default key to be 256 bits to meet security requirements. * Added basic implementation of web link resolving favicon. Needs lots more work and testing on all OSes. * Implemented ability to see links and click on them for an individual chapter. * Hooked up the ability to set Series web links. * Render out the web link * Refactored out the favicon so there is a backup in case it fails. Refactored the baseline image placeholders to be dark mode since that is the default. * Added Robbie's nice error weblink fallbacks.
This commit is contained in:
parent
23fde65a7b
commit
bd8a1821a7
37 changed files with 4272 additions and 80 deletions
|
@ -41,4 +41,5 @@ export interface Chapter {
|
|||
* 'Volume number'. Only available for SeriesDetail
|
||||
*/
|
||||
volumeTitle?: string;
|
||||
webLinks: string;
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ export interface SeriesMetadata {
|
|||
releaseYear: number;
|
||||
language: string;
|
||||
publicationStatus: PublicationStatus;
|
||||
webLinks: string;
|
||||
|
||||
summaryLocked: boolean;
|
||||
genresLocked: boolean;
|
||||
|
|
|
@ -14,9 +14,10 @@ export class ImageService implements OnDestroy {
|
|||
baseUrl = environment.apiUrl;
|
||||
apiKey: string = '';
|
||||
encodedKey: string = '';
|
||||
public placeholderImage = 'assets/images/image-placeholder-min.png';
|
||||
public errorImage = 'assets/images/error-placeholder2-min.png';
|
||||
public placeholderImage = 'assets/images/image-placeholder.dark-min.png';
|
||||
public errorImage = 'assets/images/error-placeholder2.dark-min.png';
|
||||
public resetCoverImage = 'assets/images/image-reset-cover-min.png';
|
||||
public errorWebLinkImage = 'assets/images/broken-white-32x32.png';
|
||||
|
||||
private onDestroy: Subject<void> = new Subject();
|
||||
|
||||
|
@ -25,9 +26,11 @@ export class ImageService implements OnDestroy {
|
|||
if (this.themeService.isDarkTheme()) {
|
||||
this.placeholderImage = 'assets/images/image-placeholder.dark-min.png';
|
||||
this.errorImage = 'assets/images/error-placeholder2.dark-min.png';
|
||||
this.errorWebLinkImage = 'assets/images/broken-white-32x32.png';
|
||||
} else {
|
||||
this.placeholderImage = 'assets/images/image-placeholder-min.png';
|
||||
this.errorImage = 'assets/images/error-placeholder2-min.png';
|
||||
this.errorWebLinkImage = 'assets/images/broken-black-32x32.png';
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -91,6 +94,10 @@ export class ImageService implements OnDestroy {
|
|||
return `${this.baseUrl}image/bookmark?chapterId=${chapterId}&apiKey=${this.encodedKey}&pageNum=${pageNum}`;
|
||||
}
|
||||
|
||||
getWebLinkImage(url: string) {
|
||||
return `${this.baseUrl}image/web-link?url=${encodeURIComponent(url)}&apiKey=${this.encodedKey}`;
|
||||
}
|
||||
|
||||
getCoverUploadImage(filename: string) {
|
||||
return `${this.baseUrl}image/cover-upload?filename=${encodeURIComponent(filename)}&apiKey=${this.encodedKey}`;
|
||||
}
|
||||
|
@ -99,6 +106,10 @@ export class ImageService implements OnDestroy {
|
|||
event.target.src = this.placeholderImage;
|
||||
}
|
||||
|
||||
updateErroredWebLinkImage(event: any) {
|
||||
event.target.src = this.errorWebLinkImage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to refresh an existing loaded image (lazysizes). If random already attached, will append another number onto it.
|
||||
* @param url Existing request url from ImageService only
|
||||
|
|
|
@ -11,60 +11,61 @@
|
|||
<ul ngbNav #nav="ngbNav" [(activeId)]="active" class="nav-pills" orientation="{{utilityService.getActiveBreakpoint() === Breakpoint.Mobile ? 'horizontal' : 'vertical'}}" style="min-width: 135px;">
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.General]">
|
||||
<a ngbNavLink>{{tabs[TabID.General]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="name" class="form-label">Name</label>
|
||||
<div class="input-group">
|
||||
<input id="name" class="form-control" formControlName="name" type="text" [class.is-invalid]="editSeriesForm.get('name')?.invalid && editSeriesForm.get('name')?.touched">
|
||||
<ng-container *ngIf="editSeriesForm.get('name')?.errors as errors">
|
||||
<div class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</div>
|
||||
</ng-container>
|
||||
<a ngbNavLink>{{tabs[TabID.General]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="name" class="form-label">Name</label>
|
||||
<div class="input-group">
|
||||
<input id="name" class="form-control" formControlName="name" type="text" [class.is-invalid]="editSeriesForm.get('name')?.invalid && editSeriesForm.get('name')?.touched">
|
||||
<ng-container *ngIf="editSeriesForm.get('name')?.errors as errors">
|
||||
<div class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="sort-name" class="form-label">Sort Name</label>
|
||||
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}"
|
||||
[class.is-invalid]="editSeriesForm.get('sortName')?.invalid && editSeriesForm.get('sortName')?.touched">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
|
||||
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
|
||||
<ng-container *ngIf="editSeriesForm.get('sortName')?.errors as errors">
|
||||
<div class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="sort-name" class="form-label">Sort Name</label>
|
||||
<div class="input-group {{series.sortNameLocked ? 'lock-active' : ''}}"
|
||||
[class.is-invalid]="editSeriesForm.get('sortName')?.invalid && editSeriesForm.get('sortName')?.touched">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'sortNameLocked' }"></ng-container>
|
||||
<input id="sort-name" class="form-control" formControlName="sortName" type="text">
|
||||
<ng-container *ngIf="editSeriesForm.get('sortName')?.errors as errors">
|
||||
<div class="invalid-feedback" *ngIf="errors.required">
|
||||
This field is required
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="localized-name" class="form-label">Localized Name</label>
|
||||
<div class="input-group {{series.localizedNameLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'localizedNameLocked' }"></ng-container>
|
||||
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
|
||||
<div class="row g-0">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="localized-name" class="form-label">Localized Name</label>
|
||||
<div class="input-group {{series.localizedNameLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: series, field: 'localizedNameLocked' }"></ng-container>
|
||||
<input id="localized-name" class="form-control" formControlName="localizedName" type="text">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0" *ngIf="metadata">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="summary" class="form-label">Summary</label>
|
||||
<div class="input-group {{metadata.summaryLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'summaryLocked' }"></ng-container>
|
||||
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
|
||||
<div class="row g-0" *ngIf="metadata">
|
||||
<div class="mb-3" style="width: 100%">
|
||||
<label for="summary" class="form-label">Summary</label>
|
||||
<div class="input-group {{metadata.summaryLocked ? 'lock-active' : ''}}">
|
||||
<ng-container [ngTemplateOutlet]="lock" [ngTemplateOutletContext]="{ item: metadata, field: 'summaryLocked' }"></ng-container>
|
||||
<textarea id="summary" class="form-control" formControlName="summary" rows="4"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.Metadata]" *ngIf="metadata">
|
||||
<a ngbNavLink>{{tabs[TabID.Metadata]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
|
@ -343,6 +344,27 @@
|
|||
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.WebLinks]" *ngIf="metadata">
|
||||
<a ngbNavLink>{{tabs[TabID.WebLinks]}}</a>
|
||||
<ng-template ngbNavContent>
|
||||
<p>Here you can add many different links to external services.</p>
|
||||
<div class="row g-0 mb-3" *ngFor="let link of WebLinks; let i = index;">
|
||||
<div class="col-lg-8 col-md-12 pe-2">
|
||||
<div class="mb-3">
|
||||
<label for="web-link--{{i}}" class="visually-hidden">Web Link</label>
|
||||
<input type="text" class="form-control" formControlName="link{{i}}" attr.id="web-link--{{i}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<button class="btn btn-secondary" (click)="addWebLink()">
|
||||
<i class="fa-solid fa-plus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">Add Link</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
|
||||
<li [ngbNavItem]="tabs[TabID.CoverImage]">
|
||||
<a ngbNavLink>{{tabs[TabID.CoverImage]}}</a>
|
||||
|
|
|
@ -26,9 +26,10 @@ enum TabID {
|
|||
General = 0,
|
||||
Metadata = 1,
|
||||
People = 2,
|
||||
CoverImage = 3,
|
||||
Related = 4,
|
||||
Info = 5,
|
||||
WebLinks = 3,
|
||||
CoverImage = 4,
|
||||
Related = 5,
|
||||
Info = 6,
|
||||
}
|
||||
|
||||
@Component({
|
||||
|
@ -49,7 +50,7 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||
|
||||
isCollapsed = true;
|
||||
volumeCollapsed: any = {};
|
||||
tabs = ['General', 'Metadata', 'People', 'Cover Image', 'Related', 'Info'];
|
||||
tabs = ['General', 'Metadata', 'People', 'Web Links', 'Cover Image', 'Related', 'Info'];
|
||||
active = this.tabs[0];
|
||||
activeTabId = TabID.General;
|
||||
editSeriesForm!: FormGroup;
|
||||
|
@ -100,6 +101,10 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||
return TabID;
|
||||
}
|
||||
|
||||
get WebLinks() {
|
||||
return this.metadata?.webLinks.split(',') || [''];
|
||||
}
|
||||
|
||||
getPersonsSettings(role: PersonRole) {
|
||||
return this.peopleSettings[role];
|
||||
}
|
||||
|
@ -168,6 +173,11 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||
this.editSeriesForm.get('publicationStatus')?.patchValue(this.metadata.publicationStatus);
|
||||
this.editSeriesForm.get('language')?.patchValue(this.metadata.language);
|
||||
this.editSeriesForm.get('releaseYear')?.patchValue(this.metadata.releaseYear);
|
||||
|
||||
this.WebLinks.forEach((link, index) => {
|
||||
this.editSeriesForm.addControl('link' + index, new FormControl(link, [Validators.required]));
|
||||
});
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.editSeriesForm.get('name')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(val => {
|
||||
|
@ -474,6 +484,13 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||
save() {
|
||||
const model = this.editSeriesForm.value;
|
||||
const selectedIndex = this.editSeriesForm.get('coverImageIndex')?.value || 0;
|
||||
this.metadata.webLinks = Object.keys(this.editSeriesForm.controls)
|
||||
.filter(key => key.startsWith('link'))
|
||||
.map(key => this.editSeriesForm.get(key)?.value)
|
||||
.filter(v => v !== null && v !== '')
|
||||
.join(',');
|
||||
|
||||
|
||||
|
||||
const apis = [
|
||||
this.seriesService.updateMetadata(this.metadata, this.collectionTags)
|
||||
|
@ -502,6 +519,12 @@ export class EditSeriesModalComponent implements OnInit, OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
addWebLink() {
|
||||
this.metadata.webLinks += ',';
|
||||
this.editSeriesForm.addControl('link' + (this.WebLinks.length - 1), new FormControl('', [Validators.required]));
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
updateCollections(tags: CollectionTag[]) {
|
||||
this.collectionTags = tags;
|
||||
this.cdRef.markForCheck();
|
||||
|
|
|
@ -71,5 +71,19 @@
|
|||
{{entity.id}}
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
<ng-container *ngIf="WebLinks.length > 0">
|
||||
<div class="vr d-none d-lg-block m-2"></div>
|
||||
<div class="col-auto">
|
||||
<app-icon-and-title label="Links" [clickable]="false" fontClasses="fa-solid fa-link" title="Links">
|
||||
<a class="me-1" [href]="link | safeHtml" *ngFor="let link of WebLinks" target="_blank" rel="noopener noreferrer" [title]="link">
|
||||
<img width="24px" height="24px" #img class="lazyload img-placeholder"
|
||||
src="data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
|
||||
[attr.data-src]="imageService.getWebLinkImage(link)"
|
||||
(error)="imageService.updateErroredWebLinkImage($event)"
|
||||
aria-hidden="true">
|
||||
</a>
|
||||
</app-icon-and-title>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
|
@ -1,4 +1,4 @@
|
|||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import { UtilityService } from 'src/app/shared/_services/utility.service';
|
||||
import { Chapter } from 'src/app/_models/chapter';
|
||||
|
@ -9,6 +9,7 @@ import { MangaFormat } from 'src/app/_models/manga-format';
|
|||
import { AgeRating } from 'src/app/_models/metadata/age-rating';
|
||||
import { Volume } from 'src/app/_models/volume';
|
||||
import { SeriesService } from 'src/app/_services/series.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entity-info-cards',
|
||||
|
@ -40,6 +41,7 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy {
|
|||
size: number = 0;
|
||||
|
||||
private readonly onDestroy: Subject<void> = new Subject();
|
||||
imageService = inject(ImageService);
|
||||
|
||||
get LibraryType() {
|
||||
return LibraryType;
|
||||
|
@ -53,6 +55,10 @@ export class EntityInfoCardsComponent implements OnInit, OnDestroy {
|
|||
return AgeRating;
|
||||
}
|
||||
|
||||
get WebLinks() {
|
||||
return this.chapter.webLinks.split(',');
|
||||
}
|
||||
|
||||
constructor(private utilityService: UtilityService, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { DOCUMENT } from '@angular/common';
|
||||
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, Inject, ChangeDetectionStrategy, ChangeDetectorRef, AfterContentChecked } from '@angular/core';
|
||||
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, Inject, ChangeDetectionStrategy, ChangeDetectorRef, AfterContentChecked, inject } from '@angular/core';
|
||||
import { FormGroup, FormControl } from '@angular/forms';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
|
@ -117,7 +117,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
|
|||
downloadInProgress: boolean = false;
|
||||
|
||||
itemSize: number = 10; // when 10 done, 16 loads
|
||||
|
||||
|
||||
/**
|
||||
* Track by function for Volume to tell when to refresh card data
|
||||
*/
|
||||
|
|
|
@ -2,6 +2,24 @@
|
|||
<app-read-more [text]="seriesSummary" [maxLength]="250"></app-read-more>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="WebLinks as links">
|
||||
<div class="row g-0 mt-2 mb-2" *ngIf="links.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Links</h5>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<a class="col me-1" [href]="link | safeHtml" target="_blank" rel="noopener noreferrer" *ngFor="let link of links" [title]="link">
|
||||
<img width="24px" height="24px" #img class="lazyload img-placeholder"
|
||||
src="data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
|
||||
[attr.data-src]="imageService.getWebLinkImage(link)"
|
||||
(error)="imageService.updateErroredWebLinkImage($event)"
|
||||
aria-hidden="true">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
|
||||
<div class="row g-0" *ngIf="seriesMetadata.genres && seriesMetadata.genres.length > 0">
|
||||
<div class="col-md-4">
|
||||
<h5>Genres</h5>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, SimpleChanges, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { ReaderService } from 'src/app/_services/reader.service';
|
||||
import { TagBadgeCursor } from '../../../shared/tag-badge/tag-badge.component';
|
||||
|
@ -9,6 +9,7 @@ import { ReadingList } from '../../../_models/reading-list';
|
|||
import { Series } from '../../../_models/series';
|
||||
import { SeriesMetadata } from '../../../_models/metadata/series-metadata';
|
||||
import { MetadataService } from '../../../_services/metadata.service';
|
||||
import { ImageService } from 'src/app/_services/image.service';
|
||||
|
||||
|
||||
@Component({
|
||||
|
@ -17,7 +18,7 @@ import { MetadataService } from '../../../_services/metadata.service';
|
|||
styleUrls: ['./series-metadata-detail.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
|
||||
export class SeriesMetadataDetailComponent implements OnChanges {
|
||||
|
||||
@Input() seriesMetadata!: SeriesMetadata;
|
||||
@Input() hasReadingProgress: boolean = false;
|
||||
|
@ -30,6 +31,8 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
|
|||
isCollapsed: boolean = true;
|
||||
hasExtendedProperites: boolean = false;
|
||||
|
||||
imageService = inject(ImageService);
|
||||
|
||||
/**
|
||||
* Html representation of Series Summary
|
||||
*/
|
||||
|
@ -47,6 +50,10 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
|
|||
return FilterQueryParam;
|
||||
}
|
||||
|
||||
get WebLinks() {
|
||||
return this.seriesMetadata?.webLinks.split(',') || [];
|
||||
}
|
||||
|
||||
constructor(public utilityService: UtilityService, public metadataService: MetadataService,
|
||||
private router: Router, public readerService: ReaderService,
|
||||
private readonly cdRef: ChangeDetectorRef) {
|
||||
|
@ -70,9 +77,6 @@ export class SeriesMetadataDetailComponent implements OnInit, OnChanges {
|
|||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
}
|
||||
|
||||
toggleView() {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
this.cdRef.markForCheck();
|
||||
|
|
BIN
UI/Web/src/assets/images/broken-black-32x32.png
Normal file
BIN
UI/Web/src/assets/images/broken-black-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 822 B |
BIN
UI/Web/src/assets/images/broken-white-32x32.png
Normal file
BIN
UI/Web/src/assets/images/broken-white-32x32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 581 B |
Loading…
Add table
Add a link
Reference in a new issue