Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions src/app/components/home/home.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)),
Expand Down
26 changes: 26 additions & 0 deletions src/app/components/item-card/item-card.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 {
<img
Expand All @@ -32,6 +34,16 @@ import { ImageStorageService } from '../../services/image-storage.service';
/>
}
</a>
@if (shouldBlur()) {
<button
type="button"
(click)="revealed.set(true)"
class="absolute inset-0 flex items-center justify-center bg-black/40 text-white text-sm font-medium backdrop-blur-[2px] cursor-pointer border-0"
aria-label="Reveal adult poster"
>
Adult — click to reveal
</button>
}
@if (item().progress) {
<span
class="absolute top-0 right-0 px-2 py-1 text-xs font-medium bg-black/60 text-white backdrop-blur-sm"
Expand Down Expand Up @@ -66,13 +78,21 @@ import { ImageStorageService } from '../../services/image-storage.service';
})
export class ItemCardComponent {
private imageStorage = inject(ImageStorageService);
private providerSettings = inject(ProviderSettingsService);
private destroyRef = inject(DestroyRef);
private destroyed = false;
item = input.required<Item>();

posterUrl = signal<string | null>(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());
Expand All @@ -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);
Comment thread
CodeWithMa marked this conversation as resolved.
});
}

private async loadPoster(id: string | undefined, loadedVersion: number): Promise<void> {
Expand Down
7 changes: 7 additions & 0 deletions src/app/components/item-list/item-list.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down
57 changes: 54 additions & 3 deletions src/app/components/settings/settings.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DatePipe } from '@angular/common';

import { ImportExportService } from '../../services/import-export.service';
import {
AdultDisplayMode,
DEFAULT_TITLE_PREFERENCE,
ProviderSettingsService,
TitleLanguage,
Expand Down Expand Up @@ -151,9 +152,53 @@ import { environment } from '../../../environments/environment';
>Include adult / NSFW results (TMDB, Jikan, AniList)</span
>
</label>
<p class="mt-2 text-xs text-light-font-secondary dark:text-dark-font-secondary">
When enabled, adult results may appear in suggestions. Default is off (SFW only).
</p>

<fieldset class="mt-4">
<legend class="text-sm font-medium text-light-font dark:text-dark-font mb-2">
Display adult items in your collection
</legend>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="radio"
name="adultDisplayMode"
value="show"
[checked]="adultDisplayMode() === 'show'"
(change)="setAdultDisplayMode('show')"
class="w-4 h-4"
/>
<span class="text-sm text-light-font dark:text-dark-font"
>Show — no filtering or blur</span
>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
type="radio"
name="adultDisplayMode"
value="blur"
[checked]="adultDisplayMode() === 'blur'"
(change)="setAdultDisplayMode('blur')"
class="w-4 h-4"
/>
<span class="text-sm text-light-font dark:text-dark-font"
>Blur posters — click to reveal</span
>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
type="radio"
name="adultDisplayMode"
value="hide"
[checked]="adultDisplayMode() === 'hide'"
(change)="setAdultDisplayMode('hide')"
class="w-4 h-4"
/>
<span class="text-sm text-light-font dark:text-dark-font"
>Hide — remove adult items from lists</span
>
</label>
</div>
</fieldset>
</div>

<div class="mt-6 pt-6 border-t border-light-border dark:border-dark-border">
Expand Down Expand Up @@ -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<AdultDisplayMode>(this.providerSettingsService.getAdultDisplayMode());
titleOrder = signal<TitlePreference>(this.providerSettingsService.getTitlePreference());
recoveryBackups = signal<{ key: string; timestamp: Date }[]>([]);

Expand Down Expand Up @@ -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':
Expand Down
30 changes: 30 additions & 0 deletions src/app/services/provider-settings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SuggestionSource, boolean> & {
includeAdult: boolean;
titlePreference: TitlePreference;
adultDisplayMode: AdultDisplayMode;
};

const PROVIDER_SOURCES: readonly SuggestionSource[] = ['tmdb', 'jikan', 'anilist'] as const;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading