Theme Viewer + Theme Updater (#2952)

This commit is contained in:
Joe Milazzo 2024-05-13 17:00:13 -05:00 committed by GitHub
parent 24302d4fcc
commit 38e7c1c131
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 4563 additions and 284 deletions

View file

@ -504,6 +504,7 @@
"version": "17.3.4",
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.4.tgz",
"integrity": "sha512-TVWjpZSI/GIXTYsmVgEKYjBckcW8Aj62DcxLNehRFR+c7UB95OY3ZFjU8U4jL0XvWPgTkkVWQVq+P6N4KCBsyw==",
"dev": true,
"dependencies": {
"@babel/core": "7.23.9",
"@jridgewell/sourcemap-codec": "^1.4.14",
@ -531,6 +532,7 @@
"version": "7.23.9",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz",
"integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5",
@ -559,12 +561,14 @@
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
@ -745,6 +749,7 @@
"version": "7.24.0",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz",
"integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.23.5",
@ -773,12 +778,14 @@
"node_modules/@babel/core/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
@ -5622,6 +5629,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
@ -5634,6 +5642,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
@ -5905,6 +5914,7 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"engines": {
"node": ">=8"
},
@ -6216,6 +6226,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@ -6507,7 +6518,8 @@
"node_modules/convert-source-map": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"dev": true
},
"node_modules/cookie": {
"version": "0.6.0",
@ -7409,6 +7421,7 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"dev": true,
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
@ -7418,6 +7431,7 @@
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@ -8526,6 +8540,7 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@ -9207,6 +9222,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
@ -11047,6 +11063,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -12436,6 +12453,7 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"dependencies": {
"picomatch": "^2.2.1"
},
@ -12447,6 +12465,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
@ -12457,7 +12476,8 @@
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"dev": true
},
"node_modules/regenerate": {
"version": "1.4.2",
@ -12925,7 +12945,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true
"dev": true
},
"node_modules/sass": {
"version": "1.71.1",
@ -13044,6 +13064,7 @@
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
@ -13058,6 +13079,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
@ -13068,7 +13090,8 @@
"node_modules/semver/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/send": {
"version": "0.18.0",
@ -14199,6 +14222,7 @@
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View file

@ -0,0 +1,3 @@
export interface SiteThemeUpdatedEvent {
themeName: string;
}

View file

@ -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;
}

View 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;
}

View file

@ -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 '';
}

View file

@ -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,

View file

@ -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) {

View file

@ -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({

View file

@ -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>

View file

@ -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',

View file

@ -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>&nbsp;{{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>

View file

@ -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;
}
}

View file

@ -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();
}
}

View file

@ -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"
},