-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (formGroup.get('query')?.value) {
+
+ }
+
+
+
+
+
+
+ {{ t('try') }}
+ anilist:159441
+ mal:20593
+ mangabaka:1331
+ hardcover:flatland
+
+
+
+
+
+
+
+
+
+ @if (bodyState() === 'results') {
+
+ {{ t('match-count', { count: matches().length }) }}
+
+ }
+
+
+
+ @switch (bodyState()) {
+ @case ('loading') {
+
+
+ {{ t('loading-alt') }}
-
-
-
-
-
-
-
-
- @if (!formGroup.get('dontMatch')?.value) {
-
- @for(item of matches(); track item.series.name) {
-
- } @empty {
- @if (!isLoading()) {
-
{{t('no-results')}}
+ }
+ @case ('empty') {
+
+
+
+ }
+ @case ('dont-match') {
+
+
+
+ }
+ @case ('no-results') {
+
+
+
+ }
+ @case ('results') {
+ @for (item of matches(); track item.series.name + '_' + item.series.provider) {
+
}
}
}
+
-
-
+
+
+
-
diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss
index d3a1cb9a9..2929cf576 100644
--- a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss
+++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.scss
@@ -1,3 +1,87 @@
-.setting-section-break {
- margin: 0 !important;
-}
\ No newline at end of file
+.match-title-prefix {
+ opacity: 0.55;
+ font-weight: 500;
+}
+
+.match-subheader {
+ padding: 0.875rem 0 0.75rem;
+ border-bottom: 1px solid var(--hr-color);
+}
+
+.match-description {
+ color: var(--text-muted-color);
+ line-height: 1.5;
+}
+
+.in-kavita-chip {
+ padding: 0.375rem 0.625rem 0.375rem 0.375rem;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--hr-color);
+ border-radius: 0.375rem;
+}
+
+.in-kavita-label {
+ font-size: 0.65625rem;
+ color: var(--text-muted-color);
+ letter-spacing: 0.06em;
+ line-height: 1.2;
+}
+
+.in-kavita-name {
+ font-size: 0.875rem;
+ color: var(--body-text-color);
+ line-height: 1.2;
+}
+
+.query-icon {
+ position: absolute;
+ left: 0.75rem;
+ color: var(--text-muted-color);
+ font-size: 0.8125rem;
+ pointer-events: none;
+}
+
+.query-input {
+ padding-left: 2.25rem;
+ padding-right: 2.25rem;
+}
+
+.query-clear {
+ position: absolute;
+ right: 0.5rem;
+ bottom: 0.6rem;
+ opacity: 0.5;
+
+ &:hover {
+ opacity: 1;
+ }
+}
+
+.provider-hints {
+ font-size: 0.75rem;
+ color: var(--text-muted-color);
+}
+
+.provider-hint {
+ padding: 0.125rem 0.4375rem;
+ border-radius: 0.25rem;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ color: var(--text-muted-color);
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.6875rem;
+}
+
+.match-count-bar {
+ font-size: 0.71875rem;
+ color: var(--text-muted-color);
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ font-weight: 600;
+ padding: 0.5rem 0 0.25rem;
+}
+
+.match-body {
+ flex: 1;
+ padding-bottom: 0.5rem;
+}
diff --git a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts
index c6c9cf395..c03f45667 100644
--- a/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts
+++ b/UI/Web/src/app/_single-module/match-series-modal/match-series-modal.component.ts
@@ -1,106 +1,142 @@
-import {ChangeDetectionStrategy, Component, inject, input, OnInit, signal} from '@angular/core';
+import {ChangeDetectionStrategy, Component, computed, effect, inject, input, OnInit, signal} from '@angular/core';
import {Series} from "../../_models/series";
import {SeriesService} from "../../_services/series.service";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
-import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
+import {NgbActiveModal, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
import {translate, TranslocoDirective} from "@jsverse/transloco";
-import {MatchSeriesResultItemComponent} from "../match-series-result-item/match-series-result-item.component";
-import {LoadingComponent} from "../../shared/loading/loading.component";
import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match";
import {ToastrService} from "ngx-toastr";
-import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
-import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
-import {ThemeService} from 'src/app/_services/theme.service';
-import {AsyncPipe} from '@angular/common';
-import {catchError, of, tap} from "rxjs";
+import {catchError, filter, of, startWith, tap} from "rxjs";
+import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop";
+import {EmptyStateComponent} from "../../shared/_components/empty-state/empty-state.component";
+import {
+ MatchSeriesResultItemComponent
+} from "../../shared/_components/match-series-result-item/match-series-result-item.component";
+import {ImageComponent} from "../../shared/image/image.component";
+import {ImageService} from "../../_services/image.service";
@Component({
- selector: 'app-match-series-modal',
- imports: [
- AsyncPipe,
- TranslocoDirective,
- MatchSeriesResultItemComponent,
- LoadingComponent,
- ReactiveFormsModule,
- SettingItemComponent,
- SettingSwitchComponent
- ],
- templateUrl: './match-series-modal.component.html',
- styleUrl: './match-series-modal.component.scss',
- changeDetection: ChangeDetectionStrategy.OnPush
+ selector: 'app-match-series-modal',
+ imports: [
+ ReactiveFormsModule,
+ TranslocoDirective,
+ NgbTooltip,
+ EmptyStateComponent,
+ MatchSeriesResultItemComponent,
+ ImageComponent,
+ ],
+ templateUrl: './match-series-modal.component.html',
+ styleUrl: './match-series-modal.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatchSeriesModalComponent implements OnInit {
private readonly seriesService = inject(SeriesService);
private readonly modalService = inject(NgbActiveModal);
private readonly toastr = inject(ToastrService);
- protected readonly themeService = inject(ThemeService);
+ private readonly imageService = inject(ImageService);
series = input.required
();
- formGroup = new FormGroup({});
+ formGroup = new FormGroup({
+ query: new FormControl('', []),
+ dontMatch: new FormControl(false, []),
+ });
+
+ protected readonly isDontMatch = toSignal(
+ this.formGroup.controls.dontMatch.valueChanges.pipe(
+ startWith(this.formGroup.controls.dontMatch.value)
+ ),
+ { initialValue: false }
+ );
+
+ private readonly _queryDisableEffect = effect(() => {
+ if (this.isDontMatch()) {
+ this.formGroup.controls.query.disable();
+ } else {
+ this.formGroup.controls.query.enable();
+ }
+ });
+
+ private readonly _autoSearchOnEnable = this.formGroup.controls.dontMatch.valueChanges.pipe(
+ filter(v => v === false),
+ takeUntilDestroyed()
+ ).subscribe(() => this.search());
+
+ protected readonly canSaveDontMatch = computed(() =>
+ this.isDontMatch() === true && !this.series().dontMatch
+ );
+
matches = signal([]);
- isLoading = signal(true);
+ isLoading = signal(false);
+ hasSearched = signal(false);
+ selectedItem = signal(null);
+ lastQuery = signal('');
+
+ protected bodyState = computed<'empty' | 'dont-match' | 'loading' | 'results' | 'no-results'>(() => {
+ if (this.isDontMatch()) return 'dont-match';
+ if (this.isLoading()) return 'loading';
+ if (!this.hasSearched()) return 'empty';
+ return this.matches().length > 0 ? 'results' : 'no-results';
+ });
+
+ protected coverImageUrl = computed(() => this.imageService.getSeriesCoverImage(this.series().id));
ngOnInit() {
- this.formGroup.addControl('query', new FormControl('', []));
- this.formGroup.addControl('dontMatch', new FormControl(this.series().dontMatch || false, []));
-
+ this.formGroup.patchValue({ dontMatch: this.series().dontMatch || false });
this.search();
}
search() {
+ if (this.isDontMatch()) return;
+
this.isLoading.set(true);
+ this.lastQuery.set(this.formGroup.value.query ?? '');
- const model: any = this.formGroup.value;
- model.seriesId = this.series().id;
-
- if (model.dontMatch) {
- this.isLoading.set(false);
- return;
- }
+ const model: any = { ...this.formGroup.value, seriesId: this.series().id };
this.seriesService.matchSeries(model).pipe(
tap(results => {
this.isLoading.set(false);
+ this.hasSearched.set(true);
this.matches.set(results);
}),
catchError(() => {
this.isLoading.set(false);
+ this.hasSearched.set(true);
return of([]);
})
).subscribe();
}
+ clearQuery() {
+ this.formGroup.get('query')?.setValue('');
+ }
+
+ selectItem(item: ExternalSeriesMatch) {
+ this.selectedItem.set(item);
+ }
+
close() {
this.modalService.dismiss();
}
- save() {
+ applyMatch() {
+ const item = this.selectedItem();
+ if (!item) return;
- const model: any = this.formGroup.value;
- model.seriesId = this.series().id;
-
- const dontMatchChanged = this.series().dontMatch !== model.dontMatch;
-
- // We need to update the dontMatch status
- if (dontMatchChanged) {
- this.seriesService.updateDontMatch(this.series().id, model.dontMatch).subscribe(_ => {
- this.modalService.close(true);
- });
- } else {
- this.toastr.success(translate('toasts.match-success'));
- this.modalService.close(true);
- }
- }
-
- selectMatch(item: ExternalSeriesMatch) {
const data = item.series;
data.tags = data.tags || [];
data.genres = data.genres || [];
- this.seriesService.updateMatch(this.series().id, item.series).subscribe(_ => {
- this.save();
+ this.seriesService.updateMatch(this.series().id, data).subscribe(() => {
+ this.toastr.success(translate('toasts.match-success'));
+ this.modalService.close(true);
});
}
+ saveDontMatch() {
+ this.seriesService.updateDontMatch(this.series().id, true).subscribe(() => {
+ this.modalService.close(true);
+ });
+ }
}
diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html
deleted file mode 100644
index 336bf448c..000000000
--- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.html
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
- @let coverUrl = item().series.coverUrl;
- @if (coverUrl) {
-
- }
-
-
-
{{item().series.name}} ({{item().matchRating | translocoPercent}})
-
- @for(synm of item().series.synonyms; track synm; let last = $last) {
- {{synm}}
- @if (!last) {
- ,
- }
- }
-
- @if (item().series.summary) {
-
- }
-
-
-
- @if (isSelected()) {
-
-
-
{{t('updating-metadata-status')}}
-
- } @else {
-
- @if ((item().series.volumes || 0) > 0 || (item().series.chapters || 0) > 0) {
- @if (item().series.plusMediaFormat === PlusMediaFormat.Comic) {
- {{t('issue-count', {num: item().series.chapters})}}
- } @else {
- {{t('volume-count', {num: item().series.volumes})}}
- {{t('chapter-count', {num: item().series.chapters})}}
- }
- } @else {
- {{t('releasing')}}
- }
-
- {{item().series.plusMediaFormat | plusMediaFormat}}
-
- }
-
-
-
diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss
deleted file mode 100644
index a6360d9c6..000000000
--- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.scss
+++ /dev/null
@@ -1,33 +0,0 @@
-.search-result {
- img {
- max-width: 6.25rem;
- min-width: 6.25rem;
- }
-}
-.title {
- font-size: 1.2rem;
- font-weight: bold;
- margin: 0;
- padding: 0;
-}
-
-.match-item-container {
- &.dark {
- background-color: var(--elevation-layer6-dark);
- }
-
- &.light {
- background-color: var(--elevation-layer6);
- }
- border-radius: 0.9375rem;
-
- &:hover {
- &.dark {
- background-color: var(--elevation-layer11-dark);
- }
-
- &.light {
- background-color: var(--elevation-layer11);
- }
- }
-}
\ No newline at end of file
diff --git a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts b/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts
deleted file mode 100644
index ad57702f7..000000000
--- a/UI/Web/src/app/_single-module/match-series-result-item/match-series-result-item.component.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import {ChangeDetectionStrategy, Component, input, output, signal} from '@angular/core';
-import {ImageComponent} from "../../shared/image/image.component";
-import {ExternalSeriesMatch} from "../../_models/series-detail/external-series-match";
-import {TranslocoPercentPipe} from "@jsverse/transloco-locale";
-import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
-import {TranslocoDirective} from "@jsverse/transloco";
-import {PlusMediaFormatPipe} from "../../_pipes/plus-media-format.pipe";
-import {LoadingComponent} from "../../shared/loading/loading.component";
-import {PlusMediaFormat} from "../../_models/series-detail/external-series-detail";
-
-@Component({
- selector: 'app-match-series-result-item',
- imports: [
- ImageComponent,
- TranslocoPercentPipe,
- ReadMoreComponent,
- TranslocoDirective,
- PlusMediaFormatPipe,
- LoadingComponent
- ],
- templateUrl: './match-series-result-item.component.html',
- styleUrl: './match-series-result-item.component.scss',
- changeDetection: ChangeDetectionStrategy.OnPush
-})
-export class MatchSeriesResultItemComponent {
-
- item = input.required();
- isDarkMode = input(true);
- selected = output();
-
- isSelected = signal(false);
-
- selectItem() {
- if (this.isSelected()) return;
-
- this.isSelected.set(true);
-
- this.selected.emit(this.item());
- }
-
- protected readonly PlusMediaFormat = PlusMediaFormat;
-}
diff --git a/UI/Web/src/app/shared/_components/confidence-chip/confidence-chip.component.html b/UI/Web/src/app/shared/_components/confidence-chip/confidence-chip.component.html
new file mode 100644
index 000000000..598f4917d
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/confidence-chip/confidence-chip.component.html
@@ -0,0 +1,5 @@
+
+ {{ pct() }}%
+ {{ t(labelKey()) }}
+
diff --git a/UI/Web/src/app/shared/_components/confidence-chip/confidence-chip.component.scss b/UI/Web/src/app/shared/_components/confidence-chip/confidence-chip.component.scss
new file mode 100644
index 000000000..843fe31b2
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/confidence-chip/confidence-chip.component.scss
@@ -0,0 +1,25 @@
+.confidence-chip {
+ padding: 0.375rem 0.625rem;
+ min-width: 4rem;
+ border-radius: 0.375rem;
+ background: color-mix(in srgb, var(--chip-color) 8%, transparent);
+ border: 1px solid color-mix(in srgb, var(--chip-color) 20%, transparent);
+}
+
+.chip-pct {
+ font-size: 1.125rem;
+ color: var(--chip-color);
+}
+
+.chip-unit {
+ font-size: 0.6875rem;
+ opacity: 0.6;
+ font-weight: 500;
+}
+
+.chip-label {
+ font-size: 0.59375rem;
+ color: var(--chip-color);
+ opacity: 0.85;
+ letter-spacing: 0.06em;
+}
diff --git a/UI/Web/src/app/shared/_components/confidence-chip/confidence-chip.component.ts b/UI/Web/src/app/shared/_components/confidence-chip/confidence-chip.component.ts
new file mode 100644
index 000000000..195ca505f
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/confidence-chip/confidence-chip.component.ts
@@ -0,0 +1,29 @@
+import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core';
+import {TranslocoDirective} from "@jsverse/transloco";
+
+@Component({
+ selector: 'app-confidence-chip',
+ imports: [TranslocoDirective],
+ templateUrl: './confidence-chip.component.html',
+ styleUrl: './confidence-chip.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ConfidenceChipComponent {
+ pct = input.required();
+
+ protected colorVar = computed(() => {
+ const p = this.pct();
+ if (p >= 90) return 'var(--match-confidence-chip-strong-color)';
+ if (p >= 70) return 'var(--match-confidence-chip-likely-color)';
+ if (p >= 55) return 'var(--match-confidence-chip-weak-color)';
+ return 'var(--match-confidence-chip-doubt-color)';
+ });
+
+ protected labelKey = computed(() => {
+ const p = this.pct();
+ if (p >= 90) return 'strong';
+ if (p >= 70) return 'likely';
+ if (p >= 55) return 'weak';
+ return 'doubt';
+ });
+}
diff --git a/UI/Web/src/app/shared/_components/empty-state/empty-state.component.html b/UI/Web/src/app/shared/_components/empty-state/empty-state.component.html
new file mode 100644
index 000000000..7aca96f1b
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/empty-state/empty-state.component.html
@@ -0,0 +1,9 @@
+
+
+
+
+
{{ t(titleKey()) }}
+ @if (descriptionKey()) {
+
{{ t(descriptionKey(), descriptionParams()) }}
+ }
+
diff --git a/UI/Web/src/app/shared/_components/empty-state/empty-state.component.scss b/UI/Web/src/app/shared/_components/empty-state/empty-state.component.scss
new file mode 100644
index 000000000..d3dc5b0db
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/empty-state/empty-state.component.scss
@@ -0,0 +1,30 @@
+.empty-state-container {
+ font-size: 0.8125rem;
+ color: var(--text-muted-color);
+ line-height: 1.6;
+}
+
+.empty-state-icon {
+ width: 3.5rem;
+ height: 3.5rem;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--hr-color);
+ font-size: 1.125rem;
+ opacity: 0.6;
+
+ &.is-error {
+ background: rgba(189, 54, 47, 0.10);
+ border-color: rgba(189, 54, 47, 0.35);
+ color: var(--match-confidence-chip-doubt-color);
+ opacity: 1;
+ }
+}
+
+.empty-state-title {
+ font-size: 0.875rem;
+ color: var(--body-text-color);
+}
+
+.empty-state-desc {
+ color: var(--text-muted-color);
+}
diff --git a/UI/Web/src/app/shared/_components/empty-state/empty-state.component.ts b/UI/Web/src/app/shared/_components/empty-state/empty-state.component.ts
new file mode 100644
index 000000000..339466965
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/empty-state/empty-state.component.ts
@@ -0,0 +1,17 @@
+import {ChangeDetectionStrategy, Component, input} from '@angular/core';
+import {TranslocoDirective} from "@jsverse/transloco";
+
+@Component({
+ selector: 'app-empty-state',
+ imports: [TranslocoDirective],
+ templateUrl: './empty-state.component.html',
+ styleUrl: './empty-state.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class EmptyStateComponent {
+ isError = input(false);
+ titleKey = input.required();
+ descriptionKey = input('');
+ i18nPrefix = input('');
+ descriptionParams = input>({});
+}
diff --git a/UI/Web/src/app/shared/_components/external-metadata-detail/external-metadata-detail.component.ts b/UI/Web/src/app/shared/_components/external-metadata-detail/external-metadata-detail.component.ts
index 525e4255d..dc4cfd379 100644
--- a/UI/Web/src/app/shared/_components/external-metadata-detail/external-metadata-detail.component.ts
+++ b/UI/Web/src/app/shared/_components/external-metadata-detail/external-metadata-detail.component.ts
@@ -9,7 +9,7 @@ const URLS = {
aniListId: 'https://anilist.co/manga/{id}/',
malId: 'https://myanimelist.net/manga/{id}/',
mangaBakaId: 'https://mangabaka.org/{id}',
- hardcoverId: null,
+ hardcoverId: 'https://hardcover.app/id/series/{id}',
comicVineId: null,
metronId: null,
cbrId: null,
diff --git a/UI/Web/src/app/shared/_components/match-series-result-item/match-series-result-item.component.html b/UI/Web/src/app/shared/_components/match-series-result-item/match-series-result-item.component.html
new file mode 100644
index 000000000..55ce71558
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/match-series-result-item/match-series-result-item.component.html
@@ -0,0 +1,91 @@
+
+
+ @if (isSelected()) {
+
+
+
+
{{ t('selected-alt') }}
+ }
+
+
+
+
+
+
+
{{ item().series.name }}
+
+
+
+
+
+
+
+ @if (item().series.mangaBakaId) {
+
+ }
+ @if (item().series.aniListId) {
+
+ }
+ @if (item().series.malId) {
+
+ }
+ @if (item().series.hardcoverId) {
+
+ }
+
+
+ @if (showSynonyms() && matchedSynonyms().length > 0) {
+
+
+
{{ t('matched-alt') }}
+
"{{ matchedSynonyms()[0] }}"
+ @if (matchedSynonyms().length > 1) {
+
+ +{{ matchedSynonyms().length - 1 }} {{ t('more') }}
+
+
+ {{ matchedSynonyms().length }} {{ t('matched-alt-count') }}
+
+ @for (s of matchedSynonyms(); track s) {
+ - "{{ s }}"
+ }
+
+
+ }
+
+ }
+
+
{{ item().series.summary }}
+
+
+
+
+
+
diff --git a/UI/Web/src/app/shared/_components/match-series-result-item/match-series-result-item.component.scss b/UI/Web/src/app/shared/_components/match-series-result-item/match-series-result-item.component.scss
new file mode 100644
index 000000000..b09acea63
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/match-series-result-item/match-series-result-item.component.scss
@@ -0,0 +1,80 @@
+.match-result-row {
+ margin: 0.375rem 0;
+ padding: 0.75rem;
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid var(--hr-color);
+ border-radius: 0.375rem;
+ cursor: pointer;
+ transition: background 0.15s ease, border-color 0.15s ease;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.04);
+ }
+
+ &.selected {
+ background: rgba(74, 198, 148, 0.10);
+ border-color: rgba(74, 198, 148, 0.55);
+ }
+}
+
+.selected-check {
+ top: 0.625rem;
+ right: 0.625rem;
+ width: 1.25rem;
+ height: 1.25rem;
+ background: var(--primary-color);
+ font-size: 0.625rem;
+ color: #fff;
+}
+
+.match-result-content {
+ min-width: 0;
+}
+
+.match-result-name {
+ margin: 0;
+ font-size: 1.0625rem;
+ line-height: 1.2;
+ color: var(--body-text-color);
+}
+
+.match-result-meta {
+ font-size: 0.71875rem;
+ color: var(--text-muted-color);
+}
+
+.match-result-synonyms {
+ font-size: 0.71875rem;
+ color: var(--text-muted-color);
+}
+
+.synonym-first {
+ color: var(--text-muted-color);
+}
+
+.synonym-more {
+ padding: 0.0625rem 0.4375rem;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.10);
+ color: var(--text-muted-color);
+ font-size: 0.65625rem;
+ line-height: 1.4;
+ cursor: help;
+ user-select: none;
+}
+
+.match-result-synopsis {
+ font-size: 0.78125rem;
+ line-height: 1.55;
+ color: var(--body-text-color);
+ opacity: 0.78;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ overflow: hidden;
+}
+
+.match-result-rail {
+ padding-top: 0.25rem;
+ min-width: 4.75rem;
+}
diff --git a/UI/Web/src/app/shared/_components/match-series-result-item/match-series-result-item.component.ts b/UI/Web/src/app/shared/_components/match-series-result-item/match-series-result-item.component.ts
new file mode 100644
index 000000000..400cf4ce7
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/match-series-result-item/match-series-result-item.component.ts
@@ -0,0 +1,59 @@
+import {ChangeDetectionStrategy, Component, computed, input, output} from '@angular/core';
+import {ExternalSeriesMatch} from "../../../_models/series-detail/external-series-match";
+import {ScrobbleProvider} from "../../../_services/scrobbling.service";
+import {TranslocoDirective} from "@jsverse/transloco";
+import {NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
+import {ImageComponent} from "../../image/image.component";
+import {MediaFormatPillComponent} from "../media-format-pill/media-format-pill.component";
+import {ScrobbleProviderTagBadgeComponent} from "../scrobble-provider-tag-badge/scrobble-provider-tag-badge.component";
+import {MatchStatusDotComponent} from "../match-status-dot/match-status-dot.component";
+import {ConfidenceChipComponent} from "../confidence-chip/confidence-chip.component";
+
+@Component({
+ selector: 'app-match-series-result-item',
+ imports: [
+ TranslocoDirective,
+ NgbTooltip,
+ ImageComponent,
+ MediaFormatPillComponent,
+ ScrobbleProviderTagBadgeComponent,
+ MatchStatusDotComponent,
+ ConfidenceChipComponent,
+ ],
+ templateUrl: './match-series-result-item.component.html',
+ styleUrl: './match-series-result-item.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class MatchSeriesResultItemComponent {
+ item = input.required();
+ isSelected = input(false);
+ showSynonyms = input(true);
+ query = input('');
+ selected = output();
+
+ protected readonly ScrobbleProvider = ScrobbleProvider;
+
+ protected matchedSynonyms = computed(() => {
+ const q = this.query().trim().toLowerCase();
+ if (!q || q.length < 2 || /^(anilist|mal|mangabaka|cbr|hardcover):/.test(q)) return [];
+ return (this.item().series.synonyms ?? []).filter(s => s.toLowerCase().includes(q));
+ });
+
+ protected startYear = computed(() =>
+ this.item().series.startDate ? new Date(this.item().series.startDate!).getFullYear() : null
+ );
+
+ protected endYear = computed(() =>
+ this.item().series.endDate ? new Date(this.item().series.endDate!).getFullYear() : null
+ );
+
+ protected firstAuthor = computed(() =>
+ this.item().series.staff?.find(s => s.role === 'Author')?.name ?? null
+ );
+
+ protected pct = computed(() => Math.round(this.item().matchRating * 100));
+
+ selectItem() {
+ this.selected.emit(this.item());
+ }
+}
diff --git a/UI/Web/src/app/shared/_components/match-status-dot/match-status-dot.component.html b/UI/Web/src/app/shared/_components/match-status-dot/match-status-dot.component.html
new file mode 100644
index 000000000..a58f58ad4
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/match-status-dot/match-status-dot.component.html
@@ -0,0 +1,4 @@
+
+
+ {{ isOngoing() ? t('ongoing') : t('completed') }}
+
diff --git a/UI/Web/src/app/shared/_components/match-status-dot/match-status-dot.component.scss b/UI/Web/src/app/shared/_components/match-status-dot/match-status-dot.component.scss
new file mode 100644
index 000000000..2f2f9341d
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/match-status-dot/match-status-dot.component.scss
@@ -0,0 +1,17 @@
+.status-dot-wrapper {
+ font-size: 0.71875rem;
+ color: var(--text-muted-color);
+}
+
+.status-dot {
+ width: 0.4375rem;
+ height: 0.4375rem;
+ border-radius: 50%;
+ flex-shrink: 0;
+ background: var(--primary-color);
+
+ &.ongoing {
+ background: #73c0de;
+ box-shadow: 0 0 0.375rem rgba(115, 192, 222, 0.5);
+ }
+}
diff --git a/UI/Web/src/app/shared/_components/match-status-dot/match-status-dot.component.ts b/UI/Web/src/app/shared/_components/match-status-dot/match-status-dot.component.ts
new file mode 100644
index 000000000..1e10dffe8
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/match-status-dot/match-status-dot.component.ts
@@ -0,0 +1,15 @@
+import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core';
+import {TranslocoDirective} from "@jsverse/transloco";
+
+@Component({
+ selector: 'app-match-status-dot',
+ imports: [TranslocoDirective],
+ templateUrl: './match-status-dot.component.html',
+ styleUrl: './match-status-dot.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class MatchStatusDotComponent {
+ endDate = input(undefined);
+
+ protected isOngoing = computed(() => !this.endDate());
+}
diff --git a/UI/Web/src/app/shared/_components/media-format-pill/media-format-pill.component.html b/UI/Web/src/app/shared/_components/media-format-pill/media-format-pill.component.html
new file mode 100644
index 000000000..de9e988f6
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/media-format-pill/media-format-pill.component.html
@@ -0,0 +1,4 @@
+
+
+ {{ format() | plusMediaFormat }}
+
diff --git a/UI/Web/src/app/shared/_components/media-format-pill/media-format-pill.component.scss b/UI/Web/src/app/shared/_components/media-format-pill/media-format-pill.component.scss
new file mode 100644
index 000000000..40138d979
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/media-format-pill/media-format-pill.component.scss
@@ -0,0 +1,10 @@
+.format-pill {
+ padding: 0.125rem 0.5rem;
+ background: color-mix(in srgb, var(--pill-color) 12%, transparent);
+ color: var(--pill-color);
+ border: 1px solid color-mix(in srgb, var(--pill-color) 27%, transparent);
+ font-size: 0.6875rem;
+ font-weight: 500;
+ line-height: 1.4;
+ white-space: nowrap;
+}
diff --git a/UI/Web/src/app/shared/_components/media-format-pill/media-format-pill.component.ts b/UI/Web/src/app/shared/_components/media-format-pill/media-format-pill.component.ts
new file mode 100644
index 000000000..ae7bb6162
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/media-format-pill/media-format-pill.component.ts
@@ -0,0 +1,28 @@
+import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core';
+import {PlusMediaFormat} from "../../../_models/series-detail/external-series-detail";
+import {PlusMediaFormatPipe} from "../../../_pipes/plus-media-format.pipe";
+
+interface FormatMeta {
+ cssVar: string;
+ icon: string;
+}
+
+const FORMAT_META: Record = {
+ [PlusMediaFormat.Manga]: { cssVar: '--media-format-pill-manga-color', icon: 'fa-book-open' },
+ [PlusMediaFormat.LightNovel]: { cssVar: '--media-format-pill-light-novel-color', icon: 'fa-book' },
+ [PlusMediaFormat.Comic]: { cssVar: '--media-format-pill-comic-color', icon: 'fa-border-all' },
+ [PlusMediaFormat.Book]: { cssVar: '--media-format-pill-book-color', icon: 'fa-bookmark' },
+};
+
+@Component({
+ selector: 'app-media-format-pill',
+ imports: [PlusMediaFormatPipe],
+ templateUrl: './media-format-pill.component.html',
+ styleUrl: './media-format-pill.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class MediaFormatPillComponent {
+ format = input.required();
+
+ protected meta = computed(() => FORMAT_META[this.format()]);
+}
diff --git a/UI/Web/src/app/shared/_components/scrobble-provider-tag-badge/scrobble-provider-tag-badge.component.html b/UI/Web/src/app/shared/_components/scrobble-provider-tag-badge/scrobble-provider-tag-badge.component.html
new file mode 100644
index 000000000..b2ab09cce
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/scrobble-provider-tag-badge/scrobble-provider-tag-badge.component.html
@@ -0,0 +1,19 @@
+
+ @if (showId()) {
+
+
+ {{ id() }}
+
+ } @else {
+
+
+ {{ provider() | scrobbleProviderName }}
+
+ }
+
diff --git a/UI/Web/src/app/shared/_components/scrobble-provider-tag-badge/scrobble-provider-tag-badge.component.scss b/UI/Web/src/app/shared/_components/scrobble-provider-tag-badge/scrobble-provider-tag-badge.component.scss
new file mode 100644
index 000000000..a74266a31
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/scrobble-provider-tag-badge/scrobble-provider-tag-badge.component.scss
@@ -0,0 +1,20 @@
+.source-badge {
+ height: 1.375rem;
+ padding: 0 0.5625rem 0 0.3125rem;
+ background: color-mix(in srgb, var(--badge-color) 12%, transparent);
+ border: 1px solid color-mix(in srgb, var(--badge-color) 33%, transparent);
+ color: var(--badge-color);
+ font-size: 0.6875rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.id-pill {
+ height: 1.25rem;
+ padding: 0 0.4375rem 0 0.25rem;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ color: var(--text-muted-color);
+ font-size: 0.65625rem;
+ white-space: nowrap;
+}
diff --git a/UI/Web/src/app/shared/_components/scrobble-provider-tag-badge/scrobble-provider-tag-badge.component.ts b/UI/Web/src/app/shared/_components/scrobble-provider-tag-badge/scrobble-provider-tag-badge.component.ts
new file mode 100644
index 000000000..6802531d5
--- /dev/null
+++ b/UI/Web/src/app/shared/_components/scrobble-provider-tag-badge/scrobble-provider-tag-badge.component.ts
@@ -0,0 +1,38 @@
+import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core';
+import {ScrobbleProvider} from "../../../_services/scrobbling.service";
+import {ScrobbleProviderImageComponent} from "../scrobble-provider-image/scrobble-provider-image.component";
+import {ScrobbleProviderNamePipe} from "../../../_pipes/scrobble-provider-name.pipe";
+import {TranslocoDirective} from "@jsverse/transloco";
+import {getProviderUrl} from "../../utils/provider-url.util";
+
+const PROVIDER_BRAND_COLORS: Partial> = {
+ [ScrobbleProvider.MangaBaka]: '#7c5cff',
+ [ScrobbleProvider.AniList]: '#02a9ff',
+ [ScrobbleProvider.Mal]: '#2e51a2',
+ [ScrobbleProvider.Cbr]: '#fc8452',
+ [ScrobbleProvider.Hardcover]: '#c97aff',
+};
+
+@Component({
+ selector: 'app-scrobble-provider-tag-badge',
+ imports: [
+ ScrobbleProviderImageComponent,
+ ScrobbleProviderNamePipe,
+ TranslocoDirective,
+ ],
+ templateUrl: './scrobble-provider-tag-badge.component.html',
+ styleUrl: './scrobble-provider-tag-badge.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ScrobbleProviderTagBadgeComponent {
+ provider = input.required();
+ id = input(undefined);
+
+ protected showId = computed(() => {
+ const v = this.id();
+ return v !== null && v !== undefined && v !== 0;
+ });
+
+ protected brandColor = computed(() => PROVIDER_BRAND_COLORS[this.provider()] ?? '#888');
+ protected url = computed(() => this.showId() ? getProviderUrl(this.provider(), this.id()!) : null);
+}
diff --git a/UI/Web/src/app/shared/utils/provider-url.util.ts b/UI/Web/src/app/shared/utils/provider-url.util.ts
new file mode 100644
index 000000000..111cc158d
--- /dev/null
+++ b/UI/Web/src/app/shared/utils/provider-url.util.ts
@@ -0,0 +1,12 @@
+import {ScrobbleProvider} from "../../_services/scrobbling.service";
+
+export function getProviderUrl(provider: ScrobbleProvider, id: number): string | null {
+ switch (provider) {
+ case ScrobbleProvider.AniList: return `https://anilist.co/manga/${id}/`;
+ case ScrobbleProvider.Mal: return `https://myanimelist.net/manga/${id}/`;
+ case ScrobbleProvider.MangaBaka: return `https://mangabaka.org/${id}`;
+ case ScrobbleProvider.Cbr: return `https://comicbookroundup.com/comic-books/reviews/${id}`;
+ case ScrobbleProvider.Hardcover: return `https://hardcover.app/id/series/${id}`;
+ default: return null;
+ }
+}
diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json
index 1b180037d..2f357c750 100644
--- a/UI/Web/src/assets/langs/en.json
+++ b/UI/Web/src/assets/langs/en.json
@@ -1327,9 +1327,10 @@
"today": "Today",
"yesterday": "Yesterday",
"events-count": "{{count}} events",
- "no-events": "No events to display",
"retry": "Retry",
- "load-more-label": "Load more"
+ "load-more-label": "Load more",
+ "empty-title": "Nothing found",
+ "empty-description": "Try a different filter or perform some matches/reading to populate"
},
"audit-status-title-pipe": {
@@ -1424,25 +1425,53 @@
},
"match-series-modal": {
- "title": "Match {{seriesName}}",
- "description": "Select a match to rewire Kavita+ metadata and regenerate scrobble events. Don't Match can be used to restrict Kavita from matching metadata and scrobbling.",
+ "title-prefix": "Match",
+ "in-kavita": "In Kavita",
+ "description": "Pick a match to rewire Kavita+ metadata and regenerate scrobble events.",
+ "dont-match-hint": "Use \"Do not match\" to opt this series out entirely.",
+ "try": "Try",
+ "search": "Search",
+ "searching-alt": "Searching…",
+ "clear": "Clear search",
+ "dont-match-label": "Do not match",
+ "dont-match-tooltip": "Stop Kavita from auto-matching or scrobbling this series",
+ "match-count": "{{ count }} matches",
+ "apply-match": "Apply match",
"close": "{{common.close}}",
- "save": "{{common.save}}",
- "no-results": "Unable to find a match. Try adding the url from a supported provider and retry.",
- "query-label": "Query",
- "query-tooltip": "Enter series name, AniList/MyAnimeList/ComicBookRoundup url. Urls will use a direct lookup.",
- "dont-match-label": "Do not Match",
- "dont-match-tooltip": "Opt this series from matching and scrobbling",
- "search": "Search"
+ "loading-alt": "Loading results",
+ "empty-title": "Search for a match",
+ "empty-description": "Try a series title or paste a direct AniList, MAL, MangaBaka, CBR or Hardcover URL.",
+ "no-results-title": "No matches found",
+ "no-results-description": "Nothing came back for \"{{ query }}\". Try a shorter query, romanized title, or a direct ID.",
+ "dont-match-active-title": "Do not match enabled",
+ "dont-match-active-description": "This series is opted out of Kavita+ matching and scrobbling.",
+ "save": "{{common.save}}"
},
"match-series-result-item": {
- "volume-count": "{{server-stats.volume-count}}",
- "chapter-count": "{{common.chapter-count}}",
- "issue-count": "{{common.issue-count}}",
- "releasing": "Releasing",
- "details": "View page",
- "updating-metadata-status": "Updating Metadata"
+ "matched-alt": "Matched alt:",
+ "more": "more",
+ "matched-alt-count": "matched alt titles",
+ "selected-alt": "Selected",
+ "volume-count": "{{ num }} vol",
+ "chapter-count": "{{ num }} ch",
+ "issue-count": "{{ num }} issues"
+ },
+
+ "scrobble-provider-tag-badge": {
+ "view-on": "View on {{ provider }}"
+ },
+
+ "confidence-chip": {
+ "strong": "Strong",
+ "likely": "Likely",
+ "weak": "Weak",
+ "doubt": "Doubt"
+ },
+
+ "match-status-dot": {
+ "ongoing": "Ongoing",
+ "completed": "Completed"
},
"metadata-fields": {
diff --git a/UI/Web/src/theme/themes/dark.scss b/UI/Web/src/theme/themes/dark.scss
index ccb071962..85a447efa 100644
--- a/UI/Web/src/theme/themes/dark.scss
+++ b/UI/Web/src/theme/themes/dark.scss
@@ -517,9 +517,21 @@
--activity-card-client-device-badge-bg-color: #3b82f6;
/** KavitaPlus Audit Log **/
- --audit-log-metadata-color: #4AC694;
- --audit-log-scrobble-color: #FAC858;
- --audit-log-match-color: #5470C6;
- --audit-log-sync-color: #73C0DE;
+ --audit-log-metadata-color: #4AC694;
+ --audit-log-scrobble-color: #FAC858;
+ --audit-log-match-color: #5470C6; // Maybe #a5bbff is a better color (color contrast)
+ --audit-log-sync-color: #73C0DE;
+
+ /** Match confidence chip */
+ --match-confidence-chip-strong-color: var(--primary-color);
+ --match-confidence-chip-likely-color: #7ec99e;
+ --match-confidence-chip-weak-color: var(--warning-color);
+ --match-confidence-chip-doubt-color: var(--error-color);
+
+ /** Media format pill */
+ --media-format-pill-manga-color: var(--primary-color);
+ --media-format-pill-light-novel-color: #a5bbff;
+ --media-format-pill-comic-color: #fc8452;
+ --media-format-pill-book-color: #fac858;
}