Theme Viewer + Theme Updater (#2952)
This commit is contained in:
parent
24302d4fcc
commit
38e7c1c131
35 changed files with 4563 additions and 284 deletions
|
@ -0,0 +1,3 @@
|
|||
export interface SiteThemeUpdatedEvent {
|
||||
themeName: string;
|
||||
}
|
|
@ -3,9 +3,9 @@
|
|||
*/
|
||||
export enum ThemeProvider {
|
||||
System = 1,
|
||||
User = 2
|
||||
Custom = 2,
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Theme for the whole instance
|
||||
*/
|
||||
|
@ -20,4 +20,8 @@
|
|||
* The actual class the root is defined against. It is generated at the backend.
|
||||
*/
|
||||
selector: string;
|
||||
}
|
||||
description: string;
|
||||
previewUrls: Array<string>;
|
||||
author: string;
|
||||
|
||||
}
|
||||
|
|
10
UI/Web/src/app/_models/theme/downloadable-site-theme.ts
Normal file
10
UI/Web/src/app/_models/theme/downloadable-site-theme.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export interface DownloadableSiteTheme {
|
||||
name: string;
|
||||
cssUrl: string;
|
||||
previewUrls: Array<string>;
|
||||
author: string;
|
||||
isCompatible: boolean;
|
||||
lastCompatibleVersion: string;
|
||||
alreadyDownloaded: boolean;
|
||||
description: string;
|
||||
}
|
|
@ -16,8 +16,8 @@ export class SiteThemeProviderPipe implements PipeTransform {
|
|||
switch(provider) {
|
||||
case ThemeProvider.System:
|
||||
return this.translocoService.translate('site-theme-provider-pipe.system');
|
||||
case ThemeProvider.User:
|
||||
return this.translocoService.translate('site-theme-provider-pipe.user');
|
||||
case ThemeProvider.Custom:
|
||||
return this.translocoService.translate('site-theme-provider-pipe.custom');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { UserUpdateEvent } from '../_models/events/user-update-event';
|
|||
import { User } from '../_models/user';
|
||||
import {DashboardUpdateEvent} from "../_models/events/dashboard-update-event";
|
||||
import {SideNavUpdateEvent} from "../_models/events/sidenav-update-event";
|
||||
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
|
||||
|
||||
export enum EVENTS {
|
||||
UpdateAvailable = 'UpdateAvailable',
|
||||
|
@ -98,7 +99,11 @@ export enum EVENTS {
|
|||
/**
|
||||
* User's sidenav needs to be re-rendered
|
||||
*/
|
||||
SideNavUpdate = 'SideNavUpdate'
|
||||
SideNavUpdate = 'SideNavUpdate',
|
||||
/**
|
||||
* A Theme was updated and UI should refresh to get the latest version
|
||||
*/
|
||||
SiteThemeUpdated= 'SiteThemeUpdated'
|
||||
}
|
||||
|
||||
export interface Message<T> {
|
||||
|
@ -194,6 +199,13 @@ export class MessageHubService {
|
|||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.SiteThemeUpdated, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.SiteThemeUpdated,
|
||||
payload: resp.body as SiteThemeUpdatedEvent
|
||||
});
|
||||
});
|
||||
|
||||
this.hubConnection.on(EVENTS.DashboardUpdate, resp => {
|
||||
this.messagesSource.next({
|
||||
event: EVENTS.DashboardUpdate,
|
||||
|
|
|
@ -1,25 +1,20 @@
|
|||
import { DOCUMENT } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import {
|
||||
DestroyRef,
|
||||
inject,
|
||||
Inject,
|
||||
Injectable,
|
||||
Renderer2,
|
||||
RendererFactory2,
|
||||
SecurityContext
|
||||
} from '@angular/core';
|
||||
import { DomSanitizer } from '@angular/platform-browser';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { map, ReplaySubject, take } from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { ConfirmService } from '../shared/confirm.service';
|
||||
import { NotificationProgressEvent } from '../_models/events/notification-progress-event';
|
||||
import { SiteTheme, ThemeProvider } from '../_models/preferences/site-theme';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import { EVENTS, MessageHubService } from './message-hub.service';
|
||||
import {DOCUMENT} from '@angular/common';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {DestroyRef, inject, Inject, Injectable, Renderer2, RendererFactory2, SecurityContext} from '@angular/core';
|
||||
import {DomSanitizer} from '@angular/platform-browser';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {map, ReplaySubject, take} from 'rxjs';
|
||||
import {environment} from 'src/environments/environment';
|
||||
import {ConfirmService} from '../shared/confirm.service';
|
||||
import {NotificationProgressEvent} from '../_models/events/notification-progress-event';
|
||||
import {SiteTheme, ThemeProvider} from '../_models/preferences/site-theme';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
import {EVENTS, MessageHubService} from './message-hub.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {translate} from "@ngneat/transloco";
|
||||
import {DownloadableSiteTheme} from "../_models/theme/downloadable-site-theme";
|
||||
import {NgxFileDropEntry} from "ngx-file-drop";
|
||||
import {SiteThemeUpdatedEvent} from "../_models/events/site-theme-updated-event";
|
||||
|
||||
|
||||
@Injectable({
|
||||
|
@ -52,18 +47,45 @@ export class ThemeService {
|
|||
|
||||
messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(message => {
|
||||
|
||||
if (message.event !== EVENTS.NotificationProgress) return;
|
||||
const notificationEvent = (message.payload as NotificationProgressEvent);
|
||||
if (notificationEvent.name !== EVENTS.SiteThemeProgress) return;
|
||||
if (message.event === EVENTS.NotificationProgress) {
|
||||
const notificationEvent = (message.payload as NotificationProgressEvent);
|
||||
if (notificationEvent.name !== EVENTS.SiteThemeProgress) return;
|
||||
|
||||
if (notificationEvent.eventType === 'ended') {
|
||||
if (notificationEvent.name === EVENTS.SiteThemeProgress) this.getThemes().subscribe(() => {
|
||||
if (notificationEvent.eventType === 'ended') {
|
||||
if (notificationEvent.name === EVENTS.SiteThemeProgress) this.getThemes().subscribe();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.event === EVENTS.SiteThemeUpdated) {
|
||||
const evt = (message.payload as SiteThemeUpdatedEvent);
|
||||
this.currentTheme$.pipe(take(1)).subscribe(currentTheme => {
|
||||
if (currentTheme && currentTheme.name !== EVENTS.SiteThemeProgress) return;
|
||||
console.log('Active theme has been updated, refreshing theme');
|
||||
this.setTheme(currentTheme.name);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
getDownloadableThemes() {
|
||||
return this.httpClient.get<Array<DownloadableSiteTheme>>(this.baseUrl + 'theme/browse');
|
||||
}
|
||||
|
||||
downloadTheme(theme: DownloadableSiteTheme) {
|
||||
return this.httpClient.post<SiteTheme>(this.baseUrl + 'theme/download-theme', theme);
|
||||
}
|
||||
|
||||
uploadTheme(themeFile: File, fileEntry: NgxFileDropEntry) {
|
||||
const formData = new FormData()
|
||||
formData.append('formFile', themeFile, fileEntry.relativePath);
|
||||
|
||||
return this.httpClient.post<SiteTheme>(this.baseUrl + 'theme/upload-theme', formData);
|
||||
}
|
||||
|
||||
getColorScheme() {
|
||||
return getComputedStyle(this.document.body).getPropertyValue('--color-scheme').trim();
|
||||
}
|
||||
|
@ -113,6 +135,12 @@ export class ThemeService {
|
|||
this.unsetThemes();
|
||||
}
|
||||
|
||||
deleteTheme(themeId: number) {
|
||||
return this.httpClient.delete(this.baseUrl + 'theme?themeId=' + themeId).pipe(map(() => {
|
||||
this.getThemes().subscribe(() => {});
|
||||
}));
|
||||
}
|
||||
|
||||
setDefault(themeId: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'theme/update-default', {themeId: themeId}).pipe(map(() => {
|
||||
// Refresh the cache when a default state is changed
|
||||
|
@ -148,7 +176,7 @@ export class ThemeService {
|
|||
this.unsetThemes();
|
||||
this.renderer.addClass(this.document.querySelector('body'), theme.selector);
|
||||
|
||||
if (theme.provider === ThemeProvider.User && !this.hasThemeInHead(theme.name)) {
|
||||
if (theme.provider !== ThemeProvider.System && !this.hasThemeInHead(theme.name)) {
|
||||
// We need to load the styles into the browser
|
||||
this.fetchThemeContent(theme.id).subscribe(async (content) => {
|
||||
if (content === null) {
|
||||
|
|
|
@ -13,7 +13,6 @@ import { SideNavComponent } from './sidenav/_components/side-nav/side-nav.compon
|
|||
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {ServerService} from "./_services/server.service";
|
||||
import {ImportCblModalComponent} from "./reading-list/_modals/import-cbl-modal/import-cbl-modal.component";
|
||||
import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-modal/out-of-date-modal.component";
|
||||
|
||||
@Component({
|
||||
|
|
|
@ -16,27 +16,19 @@
|
|||
</div>
|
||||
<div>by {{stack.author}} • {{t('series-count', {num: stack.seriesCount | number})}} • <span><i class="fa-solid fa-layer-group me-1" aria-hidden="true"></i>{{t('restack-count', {num: stack.restackCount | number})}}</span></div>
|
||||
</li>
|
||||
} @empty {
|
||||
@if (isLoading) {
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
} @else {
|
||||
<p>{{t('nothing-found')}}</p>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <a class="btn btn-icon" href="https://wiki.kavitareader.com/guides/features/readinglists#creating-a-reading-list-via-cbl" target="_blank" rel="noopener noreferrer">Help</a>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <button type="button" class="btn btn-secondary" (click)="close()">{{t('close')}}</button>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <button type="button" class="btn btn-primary" (click)="prevStep()" [disabled]="!canMoveToPrevStep()">{{t('prev')}}</button>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="col-auto">-->
|
||||
<!-- <button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{t(NextButtonLabel)}}</button>-->
|
||||
<!-- </div>-->
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-secondary" (click)="ngbModal.dismiss()">{{t('close')}}</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" (click)="ngbModal.dismiss()">{{t('close')}}</button>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -10,6 +10,7 @@ import {ScrobbleProvider} from "../../../_services/scrobbling.service";
|
|||
import {forkJoin} from "rxjs";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {DecimalPipe} from "@angular/common";
|
||||
import {LoadingComponent} from "../../../shared/loading/loading.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-import-mal-collection-modal',
|
||||
|
@ -18,7 +19,8 @@ import {DecimalPipe} from "@angular/common";
|
|||
TranslocoDirective,
|
||||
ReactiveFormsModule,
|
||||
Select2Module,
|
||||
DecimalPipe
|
||||
DecimalPipe,
|
||||
LoadingComponent
|
||||
],
|
||||
templateUrl: './import-mal-collection-modal.component.html',
|
||||
styleUrl: './import-mal-collection-modal.component.scss',
|
||||
|
|
|
@ -1,32 +1,141 @@
|
|||
<ng-container *transloco="let t; read:'theme-manager'">
|
||||
<div class="container-fluid">
|
||||
<div class="row mb-2">
|
||||
<div class="col-8"><h3>{{t('title')}}</h3></div>
|
||||
<div class="col-4" *ngIf="isAdmin">
|
||||
<button class="btn btn-primary float-end" (click)="scan()">
|
||||
<i class="fa fa-refresh" aria-hidden="true"></i> {{t('scan')}}
|
||||
</button>
|
||||
</div>
|
||||
<h3>{{t('title')}}</h3>
|
||||
</div>
|
||||
|
||||
<p *ngIf="isAdmin">
|
||||
{{t('looking-for-theme')}}<a href="https://github.com/Kareadita/Themes" target="_blank" rel="noopener noreferrer">{{t('looking-for-theme-continued')}}</a>
|
||||
</p>
|
||||
<p>{{t('description')}}</p>
|
||||
|
||||
<div class="row g-0">
|
||||
<h4>{{t('site-themes')}}</h4>
|
||||
<ng-container *ngFor="let theme of (themeService.themes$ | async)">
|
||||
<div class="card col-auto me-3 mb-3" style="width: 18rem;">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{theme.name | sentenceCase}}</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted">{{theme.provider | siteThemeProvider}}</h6>
|
||||
<button class="btn btn-secondary me-2" [disabled]="theme.isDefault" *ngIf="isAdmin" (click)="updateDefault(theme)">{{t('set-default')}}</button>
|
||||
<button class="btn btn-primary" (click)="applyTheme(theme)" [disabled]="currentTheme?.id === theme.id">{{currentTheme?.id === theme.id ? t('applied') : t('apply')}}</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 theme-container">
|
||||
<div class="col-md-3">
|
||||
<div class="pe-2">
|
||||
<ul style="height: 100%" class="list-group list-group-flush">
|
||||
|
||||
@for (theme of themeService.themes$ | async; track theme.name) {
|
||||
<ng-container [ngTemplateOutlet]="themeOption" [ngTemplateOutletContext]="{ $implicit: theme}"></ng-container>
|
||||
}
|
||||
|
||||
@for (theme of downloadableThemes; track theme.name) {
|
||||
<ng-container [ngTemplateOutlet]="themeOption" [ngTemplateOutletContext]="{ $implicit: theme}"></ng-container>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="col-md-9">
|
||||
@if (selectedTheme === undefined) {
|
||||
|
||||
<div class="row pb-4">
|
||||
<div class="mx-auto">
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="d-flex justify-content-evenly">
|
||||
@if (hasAdmin$ | async) {
|
||||
{{t('preview-default-admin')}}
|
||||
} @else {
|
||||
{{t('preview-default')}}
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@if (files && files.length > 0) {
|
||||
<app-loading [loading]="isUploadingTheme"></app-loading>
|
||||
} @else if (hasAdmin$ | async) {
|
||||
<ngx-file-drop (onFileDrop)="dropped($event)" [accept]="acceptableExtensions" [directory]="false"
|
||||
dropZoneClassName="file-upload" contentClassName="file-upload-zone">
|
||||
|
||||
<ng-template ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
|
||||
<div class="row g-0 mt-3 pb-3">
|
||||
<div class="mx-auto">
|
||||
<div class="row g-0 mb-3">
|
||||
<i class="fa fa-file-upload mx-auto" style="font-size: 24px; width: 20px;" aria-hidden="true"></i>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="d-flex justify-content-evenly">
|
||||
<span class="pe-0" href="javascript:void(0)">{{t('drag-n-drop')}}</span>
|
||||
<span class="ps-1 pe-1">•</span>
|
||||
<a class="pe-0" href="javascript:void(0)" (click)="openFileSelector()">{{t('upload')}}<span class="phone-hidden"> {{t('upload-continued')}}</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
|
||||
</ngx-file-drop>
|
||||
}
|
||||
|
||||
}
|
||||
@else {
|
||||
<h4>
|
||||
{{selectedTheme.name | sentenceCase}}
|
||||
<div class="float-end">
|
||||
@if (selectedTheme.isSiteTheme) {
|
||||
@if (selectedTheme.name !== 'Dark') {
|
||||
<button class="btn btn-danger me-1" (click)="deleteTheme(selectedTheme.site!)">{{t('delete')}}</button>
|
||||
}
|
||||
@if (hasAdmin$ | async) {
|
||||
<button class="btn btn-secondary me-1" [disabled]="selectedTheme.site?.isDefault" (click)="updateDefault(selectedTheme.site!)">{{t('set-default')}}</button>
|
||||
}
|
||||
<button class="btn btn-primary me-1" [disabled]="currentTheme && selectedTheme.name === currentTheme.name" (click)="applyTheme(selectedTheme.site!)">{{t('apply')}}</button>
|
||||
} @else {
|
||||
<button class="btn btn-primary" [disabled]="selectedTheme.downloadable?.alreadyDownloaded" (click)="downloadTheme(selectedTheme.downloadable!)">{{t('download')}}</button>
|
||||
}
|
||||
</div>
|
||||
</h4>
|
||||
@if(!selectedTheme.isSiteTheme) {
|
||||
<p>{{selectedTheme.downloadable!.description | defaultValue}}</p>
|
||||
|
||||
<app-carousel-reel [items]="selectedTheme.downloadable!.previewUrls" title="Preview">
|
||||
<ng-template #carouselItem let-item>
|
||||
<a [href]="item | safeUrl" target="_blank" rel="noopener noreferrer">
|
||||
<app-image [imageUrl]="item" height="100px" width="160px"></app-image>
|
||||
</a>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
} @else {
|
||||
<p>{{selectedTheme.site!.description | defaultValue}}</p>
|
||||
|
||||
<app-carousel-reel [items]="selectedTheme.site!.previewUrls" title="Preview">
|
||||
<ng-template #carouselItem let-item>
|
||||
<a [href]="item | safeUrl" target="_blank" rel="noopener noreferrer">
|
||||
<app-image [imageUrl]="item" height="100px" width="160px"></app-image>
|
||||
</a>
|
||||
</ng-template>
|
||||
</app-carousel-reel>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #themeOption let-item>
|
||||
@if (item !== undefined) {
|
||||
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start {{selectedTheme && selectedTheme.name === item.name ? 'active' : ''}}" (click)="selectTheme(item)">
|
||||
<div class="ms-2 me-auto">
|
||||
<div class="fw-bold">{{item.name | sentenceCase}}</div>
|
||||
|
||||
@if (item.hasOwnProperty('provider')) {
|
||||
{{item.provider | siteThemeProvider}}
|
||||
} @else if (item.hasOwnProperty('lastCompatibleVersion')) {
|
||||
{{ThemeProvider.Custom | siteThemeProvider}} • v{{item.lastCompatibleVersion}}
|
||||
}
|
||||
@if (currentTheme && item.name === currentTheme.name) {
|
||||
• {{t('active-theme')}}
|
||||
}
|
||||
</div>
|
||||
@if (item.hasOwnProperty('isDefault') && item.isDefault) {
|
||||
<i class="fa-solid fa-star" [attr.aria-label]="t('default-theme')"></i>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
|
||||
</ng-container>
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
//.theme-container {
|
||||
// max-height: calc(100 * var(--vh));
|
||||
// overflow-y: auto;
|
||||
//}
|
||||
|
||||
.chooser {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 158px);
|
||||
grid-gap: 0.5rem;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
ngx-file-drop ::ng-deep > div {
|
||||
// styling for the outer drop box
|
||||
width: 100%;
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 5px;
|
||||
height: 100px;
|
||||
margin: auto;
|
||||
|
||||
> div {
|
||||
// styling for the inner box (template)
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
}
|
||||
}
|
|
@ -6,17 +6,36 @@ import {
|
|||
inject,
|
||||
} from '@angular/core';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { distinctUntilChanged, take } from 'rxjs';
|
||||
import {distinctUntilChanged, map, take} from 'rxjs';
|
||||
import { ThemeService } from 'src/app/_services/theme.service';
|
||||
import { SiteTheme } from 'src/app/_models/preferences/site-theme';
|
||||
import {SiteTheme, ThemeProvider} from 'src/app/_models/preferences/site-theme';
|
||||
import { User } from 'src/app/_models/user';
|
||||
import { AccountService } from 'src/app/_services/account.service';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import { SiteThemeProviderPipe } from '../../_pipes/site-theme-provider.pipe';
|
||||
import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe';
|
||||
import { NgIf, NgFor, AsyncPipe } from '@angular/common';
|
||||
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {tap} from "rxjs/operators";
|
||||
import {NgIf, NgFor, AsyncPipe, NgTemplateOutlet} from '@angular/common';
|
||||
import {translate, TranslocoDirective} from "@ngneat/transloco";
|
||||
import {shareReplay} from "rxjs/operators";
|
||||
import {CarouselReelComponent} from "../../carousel/_components/carousel-reel/carousel-reel.component";
|
||||
import {SeriesCardComponent} from "../../cards/series-card/series-card.component";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {DownloadableSiteTheme} from "../../_models/theme/downloadable-site-theme";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {SafeUrlPipe} from "../../_pipes/safe-url.pipe";
|
||||
import {ScrobbleProvider} from "../../_services/scrobbling.service";
|
||||
import {ConfirmService} from "../../shared/confirm.service";
|
||||
import {FileSystemFileEntry, NgxFileDropEntry, NgxFileDropModule} from "ngx-file-drop";
|
||||
import {ReactiveFormsModule} from "@angular/forms";
|
||||
import {Select2Module} from "ng-select2-component";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
|
||||
interface ThemeContainer {
|
||||
downloadable?: DownloadableSiteTheme;
|
||||
site?: SiteTheme;
|
||||
isSiteTheme: boolean;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-theme-manager',
|
||||
|
@ -24,51 +43,125 @@ import {tap} from "rxjs/operators";
|
|||
styleUrls: ['./theme-manager.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgIf, NgFor, AsyncPipe, SentenceCasePipe, SiteThemeProviderPipe, TranslocoDirective]
|
||||
imports: [NgIf, NgFor, AsyncPipe, SentenceCasePipe, SiteThemeProviderPipe, TranslocoDirective, CarouselReelComponent, SeriesCardComponent, ImageComponent, DefaultValuePipe, NgTemplateOutlet, SafeUrlPipe, NgxFileDropModule, ReactiveFormsModule, Select2Module, LoadingComponent]
|
||||
})
|
||||
export class ThemeManagerComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly themeService = inject(ThemeService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
|
||||
protected readonly ThemeProvider = ThemeProvider;
|
||||
protected readonly ScrobbleProvider = ScrobbleProvider;
|
||||
|
||||
currentTheme: SiteTheme | undefined;
|
||||
isAdmin: boolean = false;
|
||||
user: User | undefined;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly translocService = inject(TranslocoService);
|
||||
selectedTheme: ThemeContainer | undefined;
|
||||
downloadableThemes: Array<DownloadableSiteTheme> = [];
|
||||
hasAdmin$ = this.accountService.currentUser$.pipe(
|
||||
takeUntilDestroyed(this.destroyRef), shareReplay({refCount: true, bufferSize: 1}),
|
||||
map(c => c && this.accountService.hasAdminRole(c))
|
||||
);
|
||||
|
||||
files: NgxFileDropEntry[] = [];
|
||||
acceptableExtensions = ['.css'].join(',');
|
||||
isUploadingTheme: boolean = false;
|
||||
|
||||
|
||||
constructor(public themeService: ThemeService, private accountService: AccountService,
|
||||
private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) {
|
||||
|
||||
themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()).subscribe(theme => {
|
||||
constructor() {
|
||||
|
||||
this.loadDownloadableThemes();
|
||||
|
||||
this.themeService.currentTheme$.pipe(takeUntilDestroyed(this.destroyRef), distinctUntilChanged()).subscribe(theme => {
|
||||
this.currentTheme = theme;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.user = user;
|
||||
this.isAdmin = accountService.hasAdminRole(user);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
loadDownloadableThemes() {
|
||||
this.themeService.getDownloadableThemes().subscribe(d => {
|
||||
this.downloadableThemes = d;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
applyTheme(theme: SiteTheme) {
|
||||
if (!this.user) return;
|
||||
async deleteTheme(theme: SiteTheme) {
|
||||
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-theme'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pref = Object.assign({}, this.user.preferences);
|
||||
pref.theme = theme;
|
||||
this.accountService.updatePreferences(pref).subscribe();
|
||||
this.themeService.deleteTheme(theme.id).subscribe(_ => {
|
||||
this.removeDownloadedTheme(theme);
|
||||
this.loadDownloadableThemes();
|
||||
});
|
||||
}
|
||||
|
||||
removeDownloadedTheme(theme: SiteTheme) {
|
||||
this.selectedTheme = undefined;
|
||||
this.downloadableThemes = this.downloadableThemes.filter(d => d.name !== theme.name);
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
applyTheme(theme: SiteTheme) {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (!user) return;
|
||||
const pref = Object.assign({}, user.preferences);
|
||||
pref.theme = theme;
|
||||
this.accountService.updatePreferences(pref).subscribe();
|
||||
// Updating theme emits the new theme to load on the themes$
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
updateDefault(theme: SiteTheme) {
|
||||
this.themeService.setDefault(theme.id).subscribe(() => {
|
||||
this.toastr.success(this.translocService.translate('theme-manager.updated-toastr', {name: theme.name}));
|
||||
this.toastr.success(translate('theme-manager.updated-toastr', {name: theme.name}));
|
||||
});
|
||||
}
|
||||
|
||||
scan() {
|
||||
this.themeService.scan().subscribe(() => {
|
||||
this.toastr.info(this.translocService.translate('theme-manager.scan-queued'));
|
||||
selectTheme(theme: SiteTheme | DownloadableSiteTheme) {
|
||||
if (theme.hasOwnProperty('provider')) {
|
||||
this.selectedTheme = {
|
||||
isSiteTheme: true,
|
||||
site: theme as SiteTheme,
|
||||
name: theme.name
|
||||
};
|
||||
} else {
|
||||
this.selectedTheme = {
|
||||
isSiteTheme: false,
|
||||
downloadable: theme as DownloadableSiteTheme,
|
||||
name: theme.name
|
||||
};
|
||||
}
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
downloadTheme(theme: DownloadableSiteTheme) {
|
||||
this.themeService.downloadTheme(theme).subscribe(theme => {
|
||||
this.removeDownloadedTheme(theme);
|
||||
});
|
||||
}
|
||||
|
||||
public dropped(files: NgxFileDropEntry[]) {
|
||||
this.files = files;
|
||||
for (const droppedFile of files) {
|
||||
// Is it a file?
|
||||
if (droppedFile.fileEntry.isFile) {
|
||||
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
|
||||
fileEntry.file((file: File) => {
|
||||
this.themeService.uploadTheme(file, droppedFile).subscribe(t => {
|
||||
this.isUploadingTheme = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
this.isUploadingTheme = true;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -182,15 +182,22 @@
|
|||
|
||||
"theme-manager": {
|
||||
"title": "Theme Manager",
|
||||
"looking-for-theme": "Looking for a light or e-ink theme? We have some custom themes you can use on our ",
|
||||
"looking-for-theme-continued": "theme github.",
|
||||
"scan": "Scan",
|
||||
"description": "Kavita comes in my colors, find a color scheme that meets your needs or build one yourself and share it. Themes may be applied for your account or applied to all accounts.",
|
||||
"site-themes": "Site Themes",
|
||||
"set-default": "Set Default",
|
||||
"default-theme": "Default theme",
|
||||
"download": "{{changelog.download}}",
|
||||
"apply": "{{common.apply}}",
|
||||
"applied": "Applied",
|
||||
"active-theme": "Active",
|
||||
"updated-toastr": "Site default has been updated to {{name}}",
|
||||
"scan-queued": "A site theme scan has been queued"
|
||||
"scan-queued": "A site theme scan has been queued",
|
||||
"delete": "{{common.delete}}",
|
||||
"drag-n-drop": "{{cover-image-chooser.drag-n-drop}}",
|
||||
"upload": "{{cover-image-chooser.upload}}",
|
||||
"upload-continued": "a css file",
|
||||
"preview-default": "Select a theme first",
|
||||
"preview-default-admin": "Select a theme first or upload one manually"
|
||||
},
|
||||
|
||||
"theme": {
|
||||
|
@ -212,7 +219,7 @@
|
|||
|
||||
"site-theme-provider-pipe": {
|
||||
"system": "System",
|
||||
"user": "User"
|
||||
"custom": "{{device-platform-pipe.custom}}"
|
||||
},
|
||||
|
||||
"manage-devices": {
|
||||
|
@ -1583,14 +1590,17 @@
|
|||
"promote-tooltip": "Promotion means that the collection can be seen server-wide, not just for you. All series within this collection will still have user-access restrictions placed on them."
|
||||
},
|
||||
|
||||
|
||||
"browse-themes-modal": {
|
||||
"title": "Browse Themes"
|
||||
},
|
||||
|
||||
"import-mal-collection-modal": {
|
||||
"close": "{{common.close}}",
|
||||
"title": "MAL Interest Stack Import",
|
||||
"description": "Import your MAL Interest Stacks and create Collections within Kavita",
|
||||
"series-count": "{{common.series-count}}",
|
||||
"restack-count": "{{num}} Restacks"
|
||||
"restack-count": "{{num}} Restacks",
|
||||
"nothing-found": ""
|
||||
},
|
||||
|
||||
"edit-chapter-progress": {
|
||||
|
@ -1946,7 +1956,10 @@
|
|||
"rejected-cover-upload": "The image could not be fetched due to server refusing request. Please download and upload from file instead.",
|
||||
"invalid-confirmation-url": "Invalid confirmation url",
|
||||
"invalid-confirmation-email": "Invalid confirmation email",
|
||||
"invalid-password-reset-url": "Invalid reset password url"
|
||||
"invalid-password-reset-url": "Invalid reset password url",
|
||||
"delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete",
|
||||
"theme-manual-upload": "There was an issue creating Theme from manual upload",
|
||||
"theme-already-in-use": "Theme already exists by that name"
|
||||
},
|
||||
|
||||
"metadata-builder": {
|
||||
|
@ -2185,7 +2198,8 @@
|
|||
"confirm-delete-collections": "Are you sure you want to delete multiple collections?",
|
||||
"collections-deleted": "Collections deleted",
|
||||
"pdf-book-mode-screen-size": "Screen too small for Book mode",
|
||||
"stack-imported": "Stack Imported"
|
||||
"stack-imported": "Stack Imported",
|
||||
"confirm-delete-theme": "Removing this theme will delete it from the disk. You can grab it from temp directory before removal"
|
||||
|
||||
},
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue