diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts index 0cac5b2..2606419 100644 --- a/src/app/components/home/home.component.ts +++ b/src/app/components/home/home.component.ts @@ -6,6 +6,7 @@ import { WatchListService } from '../../services/watch-list.service'; import { ItemCardComponent } from '../item-card/item-card.component'; import { Item, ItemType } from '../../models/item.model'; import { isEpisodicType } from '../../domain/item.constants'; +import { ProviderSettingsService } from '../../services/provider-settings.service'; @Component({ selector: 'app-home', @@ -215,22 +216,27 @@ import { isEpisodicType } from '../../domain/item.constants'; export class HomeComponent { private roundRobinService = inject(RoundRobinService); private watchListService = inject(WatchListService); + private providerSettings = inject(ProviderSettingsService); nextSeries = this.roundRobinService.nextSeries; nextMovie = this.roundRobinService.nextMovie; - backlogItems = computed(() => - this.watchListService - .items() - .filter((item) => item.status === 'not-started') - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()), - ); + backlogItems = computed(() => { + const adultMode = this.providerSettings.settings().adultDisplayMode; + let items = this.watchListService.items().filter((item) => item.status === 'not-started'); + if (adultMode === 'hide') { + items = items.filter((item) => !item.isAdult); + } + return items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }); - pausedItems = computed(() => - this.watchListService - .items() - .filter((item) => item.status === 'paused') - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()), - ); + pausedItems = computed(() => { + const adultMode = this.providerSettings.settings().adultDisplayMode; + let items = this.watchListService.items().filter((item) => item.status === 'paused'); + if (adultMode === 'hide') { + items = items.filter((item) => !item.isAdult); + } + return items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }); protected hasSeries = computed(() => this.watchListService.items().some((item) => isEpisodicType(item.type)), diff --git a/src/app/components/item-card/item-card.component.ts b/src/app/components/item-card/item-card.component.ts index 6d9345e..6014aeb 100644 --- a/src/app/components/item-card/item-card.component.ts +++ b/src/app/components/item-card/item-card.component.ts @@ -7,6 +7,7 @@ import { calculateProgress, getMostRecentWatchDate } from '../../utils/progress. import { statusLineColor } from '../../utils/status.utils'; import { getPlaceholderUrl } from '../../utils/tmdb-image.utils'; import { ImageStorageService } from '../../services/image-storage.service'; +import { ProviderSettingsService } from '../../services/provider-settings.service'; @Component({ selector: 'app-item-card', @@ -23,6 +24,7 @@ import { ImageStorageService } from '../../services/image-storage.service'; [src]="posterUrl()" [alt]="item().title + ' poster'" class="w-full aspect-[2/3] object-cover" + [style.filter]="shouldBlur() ? 'blur(12px)' : null" /> } @else { } + @if (shouldBlur()) { + + } @if (item().progress) { (); @@ -73,6 +86,13 @@ export class ItemCardComponent { posterUrl = signal(null); placeholderUrl = computed(() => getPlaceholderUrl()); statusColorClass = computed(() => statusLineColor(this.item().status)); + revealed = signal(false); + shouldBlur = computed(() => { + const mode = this.providerSettings.settings().adultDisplayMode; + // 'blur' blurs everywhere; 'hide' still blurs where card remains visible (next*), since next* is not filtered + // 'show' never blurs + return this.item().isAdult && mode !== 'show' && !!this.posterUrl() && !this.revealed(); + }); progressPercent = computed(() => { return calculateProgress(this.item()); @@ -91,6 +111,12 @@ export class ItemCardComponent { const version = this.imageStorage.version(); void this.loadPoster(this.item().posterId, version); }); + + effect(() => { + // Reset click-to-reveal when item identity changes + void this.item().id; + this.revealed.set(false); + }); } private async loadPoster(id: string | undefined, loadedVersion: number): Promise { diff --git a/src/app/components/item-list/item-list.component.ts b/src/app/components/item-list/item-list.component.ts index c2ace14..9de3022 100644 --- a/src/app/components/item-list/item-list.component.ts +++ b/src/app/components/item-list/item-list.component.ts @@ -24,6 +24,7 @@ import { } from '../../utils/sort.utils'; import { ItemCardComponent } from '../item-card/item-card.component'; +import { ProviderSettingsService } from '../../services/provider-settings.service'; @Component({ selector: 'app-item-list', @@ -166,6 +167,7 @@ export class ItemListComponent { private watchListService = inject(WatchListService); private groupService = inject(GroupService); private itemSortService = inject(ItemSortService); + private providerSettings = inject(ProviderSettingsService); readonly groups = this.groupService.groups; readonly itemStatuses = ITEM_STATUSES; @@ -216,6 +218,11 @@ export class ItemListComponent { const groupFilter = this.groupFilter(); const sortField = this.sortField(); const sortDirection = this.sortDirection(); + const adultMode = this.providerSettings.settings().adultDisplayMode; + + if (adultMode === 'hide') { + items = items.filter((item) => !item.isAdult); + } if (search) { items = items.filter((item) => item.title.toLowerCase().includes(search)); diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 5e32b86..6d394ee 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -4,6 +4,7 @@ import { DatePipe } from '@angular/common'; import { ImportExportService } from '../../services/import-export.service'; import { + AdultDisplayMode, DEFAULT_TITLE_PREFERENCE, ProviderSettingsService, TitleLanguage, @@ -151,9 +152,53 @@ import { environment } from '../../../environments/environment'; >Include adult / NSFW results (TMDB, Jikan, AniList) -

- When enabled, adult results may appear in suggestions. Default is off (SFW only). -

+ +
+ + Display adult items in your collection + +
+ + + +
+
@@ -402,6 +447,7 @@ export class SettingsComponent implements OnInit { jikanEnabled = signal(this.providerSettingsService.isEnabled('jikan')); anilistEnabled = signal(this.providerSettingsService.isEnabled('anilist')); includeAdult = signal(this.providerSettingsService.isAdultIncluded()); + adultDisplayMode = signal(this.providerSettingsService.getAdultDisplayMode()); titleOrder = signal(this.providerSettingsService.getTitlePreference()); recoveryBackups = signal<{ key: string; timestamp: Date }[]>([]); @@ -437,6 +483,11 @@ export class SettingsComponent implements OnInit { this.includeAdult.set(this.providerSettingsService.isAdultIncluded()); } + setAdultDisplayMode(mode: AdultDisplayMode): void { + this.providerSettingsService.setAdultDisplayMode(mode); + this.adultDisplayMode.set(this.providerSettingsService.getAdultDisplayMode()); + } + titleLanguageLabel(lang: TitleLanguage): string { switch (lang) { case 'romaji': diff --git a/src/app/services/provider-settings.service.ts b/src/app/services/provider-settings.service.ts index ae92285..e3360cb 100644 --- a/src/app/services/provider-settings.service.ts +++ b/src/app/services/provider-settings.service.ts @@ -14,17 +14,25 @@ export const DEFAULT_TITLE_PREFERENCE: readonly TitleLanguage[] = Object.freeze( 'native', ] as const); +export type AdultDisplayMode = 'show' | 'blur' | 'hide'; + +const VALID_ADULT_DISPLAY_MODES: readonly AdultDisplayMode[] = ['show', 'blur', 'hide'] as const; + +const DEFAULT_ADULT_DISPLAY_MODE: AdultDisplayMode = 'blur'; + const DEFAULT_PROVIDER_SETTINGS: ProviderSettings = { tmdb: true, jikan: true, anilist: true, includeAdult: false, titlePreference: [...DEFAULT_TITLE_PREFERENCE], + adultDisplayMode: DEFAULT_ADULT_DISPLAY_MODE, }; export type ProviderSettings = Record & { includeAdult: boolean; titlePreference: TitlePreference; + adultDisplayMode: AdultDisplayMode; }; const PROVIDER_SOURCES: readonly SuggestionSource[] = ['tmdb', 'jikan', 'anilist'] as const; @@ -79,6 +87,17 @@ export class ProviderSettingsService { this.save(next); } + getAdultDisplayMode(): AdultDisplayMode { + return this.normalizeAdultDisplayMode(this.enabled().adultDisplayMode); + } + + setAdultDisplayMode(mode: AdultDisplayMode): void { + const normalized = this.normalizeAdultDisplayMode(mode); + const next = { ...this.enabled(), adultDisplayMode: normalized }; + this.enabled.set(next); + this.save(next); + } + private load(): ProviderSettings { try { const raw = localStorage.getItem(PROVIDER_SETTINGS_KEY); @@ -106,6 +125,7 @@ export class ProviderSettingsService { ? obj['includeAdult'] : DEFAULT_PROVIDER_SETTINGS.includeAdult, titlePreference: this.normalizeTitlePreference(obj['titlePreference']), + adultDisplayMode: this.normalizeAdultDisplayMode(obj['adultDisplayMode']), }; } catch { return { @@ -115,6 +135,16 @@ export class ProviderSettingsService { } } + private normalizeAdultDisplayMode(value: unknown): AdultDisplayMode { + if ( + typeof value === 'string' && + (VALID_ADULT_DISPLAY_MODES as readonly string[]).includes(value) + ) { + return value as AdultDisplayMode; + } + return DEFAULT_ADULT_DISPLAY_MODE; + } + // Strict reset: any invalid entry (wrong length, duplicate, unknown lang) falls back to default. // Alternative lenient dedup/filter was considered but we prefer explicit reset to avoid drift. private normalizeTitlePreference(value: unknown): TitlePreference {