mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Revamped Content-Filtering + Levels & Source-Based Toggle
This commit is contained in:
@@ -1,50 +1,5 @@
|
|||||||
import type { Manga, Source } from "@types";
|
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util";
|
||||||
import type { Settings } from "@types";
|
|
||||||
import { shouldHideSource } from "@core/util";
|
|
||||||
|
|
||||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates sources by name, preferring `preferredLang` when multiple
|
|
||||||
* sources share a name. The local source (id "0") is always excluded.
|
|
||||||
*
|
|
||||||
* When `applyHide` is true, sources that fail the NSFW/block check are
|
|
||||||
* also removed — used in fan-out and cache-build paths where only
|
|
||||||
* user-visible sources should be queried.
|
|
||||||
*/
|
|
||||||
export function dedupeSourcesByLang(
|
|
||||||
sources: Source[],
|
|
||||||
preferredLang: string,
|
|
||||||
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
|
||||||
applyHide = false,
|
|
||||||
): Source[] {
|
|
||||||
const map = new Map<string, Source>();
|
|
||||||
for (const s of sources) {
|
|
||||||
if (s.id === "0") continue;
|
|
||||||
if (applyHide && shouldHideSource(s, settings)) continue;
|
|
||||||
const existing = map.get(s.name);
|
|
||||||
if (!existing) { map.set(s.name, s); continue; }
|
|
||||||
const existingPref = existing.lang === preferredLang;
|
|
||||||
const newPref = s.lang === preferredLang;
|
|
||||||
if (newPref && !existingPref) map.set(s.name, s);
|
|
||||||
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
|
||||||
}
|
|
||||||
return Array.from(map.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Manga predicate filters ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic predicate pipeline — composes multiple boolean predicates into one.
|
|
||||||
* All predicates must return true for an item to pass.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* const keep = buildFilter<Manga>(
|
|
||||||
* m => !shouldHideNsfw(m, settings),
|
|
||||||
* m => m.inLibrary,
|
|
||||||
* );
|
|
||||||
* const filtered = items.filter(keep);
|
|
||||||
*/
|
|
||||||
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
|
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
|
||||||
return (item) => predicates.every((p) => p(item));
|
return (item) => predicates.every((p) => p(item));
|
||||||
}
|
}
|
||||||
+68
-79
@@ -1,7 +1,5 @@
|
|||||||
import type { Manga, Source } from "@types";
|
import type { Manga, Source } from "@types";
|
||||||
import type { Settings } from "@types";
|
import type { Settings } from "@types";
|
||||||
|
|
||||||
// ── Class utility ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export { clsx as cn } from "clsx";
|
export { clsx as cn } from "clsx";
|
||||||
|
|
||||||
@@ -33,85 +31,98 @@ export function formatReadTime(m: number): string {
|
|||||||
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── NSFW filtering ────────────────────────────────────────────────────────────
|
// ── Content filtering ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
const STRICT_TAGS: string[] = [
|
||||||
* Default genre substrings used when no user-configured list is available.
|
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||||
* Stored as settings.nsfwFilteredTags; editable in Settings > Content.
|
"18+", "smut", "lemon", "explicit", "sexual violence",
|
||||||
*/
|
"gore", "guro", "graphic violence", "torture", "body horror",
|
||||||
export const DEFAULT_NSFW_TAGS = [
|
|
||||||
"adult",
|
|
||||||
"mature",
|
|
||||||
"hentai",
|
|
||||||
"ecchi",
|
|
||||||
"erotic", // catches "erotica", "erotic content", "erotic manga"
|
|
||||||
"pornograph", // catches "pornographic", "pornography"
|
|
||||||
"18+",
|
|
||||||
"smut",
|
|
||||||
"lemon",
|
|
||||||
"explicit",
|
|
||||||
"sexual violence",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
const MODERATE_TAGS: string[] = [
|
||||||
* Returns true if the manga's genre list contains any of the given substrings.
|
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||||
* Falls back to DEFAULT_NSFW_TAGS if no tag list is provided.
|
"18+", "smut", "lemon", "explicit", "sexual violence",
|
||||||
*/
|
];
|
||||||
export function isNsfwManga(
|
|
||||||
manga: { genre?: string[] | null },
|
type ContentFilterSettings = Pick<
|
||||||
tags: string[] = DEFAULT_NSFW_TAGS,
|
Settings,
|
||||||
): boolean {
|
"contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds"
|
||||||
return (manga.genre ?? []).some(g =>
|
>;
|
||||||
tags.some(sub => g.toLowerCase().trim().includes(sub))
|
|
||||||
);
|
function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
|
||||||
|
if (settings.contentLevel === "strict") return STRICT_TAGS;
|
||||||
|
if (settings.contentLevel === "moderate") return MODERATE_TAGS;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
|
||||||
|
if (!blockedTags.length) return false;
|
||||||
|
return genre.some(g => blockedTags.some(tag => g.toLowerCase().trim().includes(tag)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single authoritative NSFW gate used by all views.
|
* Returns true when the manga should be hidden.
|
||||||
* Returns true when the manga should be HIDDEN. Priority order:
|
* Called by all views — library, search cache, discover.
|
||||||
* 1. Source in blockedSourceIds → always hidden, even when showNsfw is on.
|
|
||||||
* 2. showNsfw globally enabled → only blocked sources are hidden.
|
|
||||||
* 3. Source in allowedSourceIds → skip isNsfw flag, but genre tags still apply.
|
|
||||||
* 4. source.isNsfw flag → hidden.
|
|
||||||
* 5. Genre tag match → hidden.
|
|
||||||
*
|
|
||||||
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
|
|
||||||
*/
|
*/
|
||||||
export function shouldHideNsfw(
|
export function shouldHideNsfw(
|
||||||
manga: Pick<Manga, "genre" | "source">,
|
manga: Pick<Manga, "genre" | "source">,
|
||||||
settings: Pick<Settings, "showNsfw" | "nsfwFilteredTags" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
settings: ContentFilterSettings,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (settings.contentLevel === "unrestricted") return false;
|
||||||
|
|
||||||
const srcId = manga.source?.id;
|
const srcId = manga.source?.id;
|
||||||
|
const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : [];
|
||||||
|
const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : [];
|
||||||
|
|
||||||
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
|
if (srcId && blocked.includes(srcId)) return true;
|
||||||
if (settings.showNsfw) return false;
|
|
||||||
|
|
||||||
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
|
const sourceAllowed = !!(srcId && allowed.includes(srcId));
|
||||||
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
const blockedTags = blockedTagsForSettings(settings);
|
||||||
|
|
||||||
return isNsfwManga(manga, settings.nsfwFilteredTags);
|
if (!sourceAllowed && manga.source?.isNsfw && settings.contentLevel === "strict") return true;
|
||||||
|
return genreMatchesBlocklist(manga.genre ?? [], blockedTags);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gate for Source objects — parallel to shouldHideNsfw for manga.
|
* Returns true when the source should be hidden.
|
||||||
* Usage: sources.filter(s => !shouldHideSource(s, settings))
|
* Used in extension lists and source fan-out.
|
||||||
*/
|
*/
|
||||||
export function shouldHideSource(
|
export function shouldHideSource(
|
||||||
source: Pick<Source, "id" | "isNsfw">,
|
source: Pick<Source, "id" | "isNsfw">,
|
||||||
settings: Pick<Settings, "showNsfw" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
settings: ContentFilterSettings,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
|
if (settings.contentLevel === "unrestricted") return false;
|
||||||
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
|
|
||||||
return !settings.showNsfw && source.isNsfw;
|
if (settings.sourceOverridesEnabled) {
|
||||||
|
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
|
||||||
|
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return settings.contentLevel === "strict";
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.isNsfw && settings.contentLevel === "strict";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
export function dedupeSourcesByLang(
|
||||||
* Deduplicates sources by name. When multiple sources share a name,
|
sources: Source[],
|
||||||
* the preferred language wins; otherwise falls back to alphabetical by lang.
|
preferredLang: string,
|
||||||
* The local source (id "0") is always excluded.
|
settings: ContentFilterSettings,
|
||||||
*/
|
applyHide = false,
|
||||||
|
): Source[] {
|
||||||
|
const map = new Map<string, Source>();
|
||||||
|
for (const s of sources) {
|
||||||
|
if (s.id === "0") continue;
|
||||||
|
if (applyHide && shouldHideSource(s, settings)) continue;
|
||||||
|
const existing = map.get(s.name);
|
||||||
|
if (!existing) { map.set(s.name, s); continue; }
|
||||||
|
const existingPref = existing.lang === preferredLang;
|
||||||
|
const newPref = s.lang === preferredLang;
|
||||||
|
if (newPref && !existingPref) map.set(s.name, s);
|
||||||
|
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||||
const byName = new Map<string, Source[]>();
|
const byName = new Map<string, Source[]>();
|
||||||
for (const src of sources) {
|
for (const src of sources) {
|
||||||
@@ -129,7 +140,6 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
|
|||||||
|
|
||||||
// ── Manga deduplication ───────────────────────────────────────────────────────
|
// ── Manga deduplication ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Strips punctuation, articles, and source suffixes for fuzzy title matching. */
|
|
||||||
export function normalizeTitle(title: string): string {
|
export function normalizeTitle(title: string): string {
|
||||||
return title
|
return title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -140,39 +150,21 @@ export function normalizeTitle(title: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strips all non-alphanumeric chars and collapses whitespace. */
|
|
||||||
function norm(s: string): string {
|
function norm(s: string): string {
|
||||||
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
|
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* First 200 normalized chars of a description — reliable cross-source fingerprint.
|
|
||||||
* Returns null if too short (< 60 chars) to be a trustworthy signal.
|
|
||||||
*/
|
|
||||||
function descFingerprint(desc: string | null | undefined): string | null {
|
function descFingerprint(desc: string | null | undefined): string | null {
|
||||||
if (!desc) return null;
|
if (!desc) return null;
|
||||||
const n = norm(desc);
|
const n = norm(desc);
|
||||||
return n.length >= 60 ? n.slice(0, 200) : null;
|
return n.length >= 60 ? n.slice(0, 200) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalized author + artist concatenation for tie-breaking.
|
|
||||||
* Returns null if no author info available.
|
|
||||||
*/
|
|
||||||
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
|
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
|
||||||
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
|
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
|
||||||
return parts.length ? parts.sort().join("|") : null;
|
return parts.length ? parts.sort().join("|") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates manga across sources using title, description, and author signals,
|
|
||||||
* plus explicit user-defined links (settings.mangaLinks).
|
|
||||||
*
|
|
||||||
* When two entries match, the better one is kept:
|
|
||||||
* - Library membership wins over non-library.
|
|
||||||
* - Otherwise higher downloadCount wins.
|
|
||||||
* - Otherwise first occurrence wins.
|
|
||||||
*/
|
|
||||||
export function dedupeMangaByTitle<T extends {
|
export function dedupeMangaByTitle<T extends {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -228,9 +220,6 @@ export function dedupeMangaByTitle<T extends {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lossless deduplication by ID only. Preserves first occurrence.
|
|
||||||
*/
|
|
||||||
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
const out: T[] = [];
|
const out: T[] = [];
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ export interface CachedManga {
|
|||||||
genreEnriched: boolean;
|
genreEnriched: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const COMMON_GENRES = [
|
export const COMMON_GENRES = [
|
||||||
"Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance",
|
"Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance",
|
||||||
"Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports",
|
"Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports",
|
||||||
@@ -66,7 +65,6 @@ export const MANGA_STATUSES: { value: string; label: string }[] = [
|
|||||||
{ value: "UNKNOWN", label: "Unknown" },
|
{ value: "UNKNOWN", label: "Unknown" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
export function buildTagFilter(
|
export function buildTagFilter(
|
||||||
tags: string[],
|
tags: string[],
|
||||||
mode: TagMode,
|
mode: TagMode,
|
||||||
@@ -90,13 +88,12 @@ export function buildTagFilter(
|
|||||||
return { and: [genrePart, statusPart] };
|
return { and: [genrePart, statusPart] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function filterSourceCache(
|
export function filterSourceCache(
|
||||||
sourceCache: Map<number, CachedManga>,
|
sourceCache: Map<number, CachedManga>,
|
||||||
tags: string[],
|
tags: string[],
|
||||||
mode: TagMode,
|
mode: TagMode,
|
||||||
statuses: string[],
|
statuses: string[],
|
||||||
settings: Pick<Settings, "showNsfw" | "nsfwFilteredTags" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
settings: Pick<Settings, "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||||
): CachedManga[] {
|
): CachedManga[] {
|
||||||
return [...sourceCache.values()].filter((m) => {
|
return [...sourceCache.values()].filter((m) => {
|
||||||
if (shouldHideNsfw(m as any, settings)) return false;
|
if (shouldHideNsfw(m as any, settings)) return false;
|
||||||
@@ -118,7 +115,6 @@ export function filterSourceCache(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function toCachedManga(
|
export function toCachedManga(
|
||||||
m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string },
|
m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string },
|
||||||
srcId: string,
|
srcId: string,
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Plus, Tag } from "phosphor-svelte";
|
import { thumbUrl, gql } from "@api/client";
|
||||||
|
import { GET_SOURCES } from "@api/queries/index";
|
||||||
import { store, updateSettings } from "@store/state.svelte";
|
import { store, updateSettings } from "@store/state.svelte";
|
||||||
import { gql, thumbUrl } from "@api/client";
|
import type { ContentLevel } from "@types/settings";
|
||||||
import { GET_SOURCES } from "@api/queries/index";
|
import type { Source } from "@types";
|
||||||
import type { Source } from "../../lib/types";
|
|
||||||
|
|
||||||
let contentSources: Source[] = $state([]);
|
let contentSources: Source[] = $state([]);
|
||||||
let contentSourcesLoading: boolean = $state(false);
|
let contentSourcesLoading: boolean = $state(false);
|
||||||
let newTagInput = $state("");
|
|
||||||
let tagsRevealed = $state(false);
|
|
||||||
let sourceSearch = $state("");
|
let sourceSearch = $state("");
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (contentSources.length === 0 && !contentSourcesLoading) loadContentSources();
|
if (store.settings.sourceOverridesEnabled && contentSources.length === 0 && !contentSourcesLoading)
|
||||||
|
loadContentSources();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadContentSources() {
|
async function loadContentSources() {
|
||||||
@@ -24,22 +23,6 @@
|
|||||||
finally { contentSourcesLoading = false; }
|
finally { contentSourcesLoading = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTag() {
|
|
||||||
const t = newTagInput.trim().toLowerCase();
|
|
||||||
if (!t) return;
|
|
||||||
const tags = store.settings.nsfwFilteredTags ?? [];
|
|
||||||
if (!tags.includes(t)) updateSettings({ nsfwFilteredTags: [...tags, t] });
|
|
||||||
newTagInput = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTag(tag: string) {
|
|
||||||
updateSettings({ nsfwFilteredTags: (store.settings.nsfwFilteredTags ?? []).filter(t => t !== tag) });
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetTags() {
|
|
||||||
updateSettings({ nsfwFilteredTags: ["adult","mature","hentai","ecchi","erotic","pornograph","18+","smut","lemon","explicit","sexual violence"] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSourceAllowed(ids: string[]) {
|
function toggleSourceAllowed(ids: string[]) {
|
||||||
const allowed = store.settings.nsfwAllowedSourceIds ?? [];
|
const allowed = store.settings.nsfwAllowedSourceIds ?? [];
|
||||||
const blocked = store.settings.nsfwBlockedSourceIds ?? [];
|
const blocked = store.settings.nsfwBlockedSourceIds ?? [];
|
||||||
@@ -72,59 +55,43 @@
|
|||||||
|
|
||||||
const contentSourcesFiltered = $derived.by(() => {
|
const contentSourcesFiltered = $derived.by(() => {
|
||||||
const q = sourceSearch.trim().toLowerCase();
|
const q = sourceSearch.trim().toLowerCase();
|
||||||
const filtered = q ? contentSources.filter(s => s.displayName.toLowerCase().includes(q) || s.lang.toLowerCase().includes(q)) : contentSources;
|
const filtered = q
|
||||||
|
? contentSources.filter(s => s.displayName.toLowerCase().includes(q) || s.lang.toLowerCase().includes(q))
|
||||||
|
: contentSources;
|
||||||
const map = new Map<string, ContentSourceGroup>();
|
const map = new Map<string, ContentSourceGroup>();
|
||||||
for (const s of filtered) {
|
for (const s of filtered) {
|
||||||
const key = s.name;
|
if (!map.has(s.name)) map.set(s.name, { name: s.name, iconUrl: s.iconUrl, isNsfw: s.isNsfw, sources: [] });
|
||||||
if (!map.has(key)) map.set(key, { name: s.name, iconUrl: s.iconUrl, isNsfw: s.isNsfw, sources: [] });
|
map.get(s.name)!.sources.push(s);
|
||||||
map.get(key)!.sources.push(s);
|
|
||||||
}
|
}
|
||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const LEVELS: { value: ContentLevel; label: string; desc: string }[] = [
|
||||||
|
{ value: "strict", label: "Strict", desc: "Hides all adult, sexual, and graphic violent content" },
|
||||||
|
{ value: "moderate", label: "Moderate", desc: "Allows violence and gore, filters sexual content" },
|
||||||
|
{ value: "unrestricted", label: "Unrestricted", desc: "No content filtering applied" },
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="s-panel">
|
<div class="s-panel">
|
||||||
|
|
||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<p class="s-section-title">Content Filter</p>
|
<p class="s-section-title">Content Level</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
<label class="s-row">
|
<div class="s-row" style="border-bottom: none; padding-bottom: 0;">
|
||||||
<div class="s-row-info"><span class="s-label">Show adult content</span><span class="s-desc">Sources and manga matching blocked tags are hidden when off</span></div>
|
<span class="s-desc">Controls what content is visible across library, search, and discover.</span>
|
||||||
<button role="switch" aria-checked={store.settings.showNsfw} aria-label="Show adult content" class="s-toggle" class:on={store.settings.showNsfw}
|
|
||||||
onclick={() => updateSettings({ showNsfw: !store.settings.showNsfw })}><span class="s-toggle-thumb"></span></button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="s-section">
|
|
||||||
<p class="s-section-title">
|
|
||||||
Blocked Genre Tags
|
|
||||||
<button class="s-btn" onclick={() => tagsRevealed = !tagsRevealed}>
|
|
||||||
{tagsRevealed ? "Hide" : `Show (${(store.settings.nsfwFilteredTags ?? []).length})`}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
<div class="s-section-body">
|
|
||||||
<div class="s-row" style="padding-bottom:var(--sp-2)">
|
|
||||||
<span class="s-desc">Manga matching any of these substrings are filtered. Case-insensitive, partial match.</span>
|
|
||||||
</div>
|
</div>
|
||||||
{#if tagsRevealed}
|
<div class="s-level-group">
|
||||||
<div class="s-tag-grid">
|
{#each LEVELS as lvl}
|
||||||
{#each (store.settings.nsfwFilteredTags ?? []) as tag}
|
{@const active = store.settings.contentLevel === lvl.value}
|
||||||
<span class="s-tag">
|
<button class="s-level-btn" class:active onclick={() => updateSettings({ contentLevel: lvl.value })}>
|
||||||
<Tag size={10} weight="light" />
|
<span class="s-level-dot" class:active></span>
|
||||||
{tag}
|
<div class="s-level-text">
|
||||||
<button class="s-tag-remove" onclick={() => removeTag(tag)} title="Remove tag">×</button>
|
<span class="s-level-label">{lvl.label}</span>
|
||||||
</span>
|
<span class="s-level-desc">{lvl.desc}</span>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
</button>
|
||||||
{/if}
|
{/each}
|
||||||
<div class="s-tag-add">
|
|
||||||
<input class="s-input full" placeholder="Add tag substring…" bind:value={newTagInput}
|
|
||||||
onkeydown={(e) => { if (e.key === "Enter") addTag(); }} />
|
|
||||||
<button class="s-btn s-btn-accent" onclick={addTag} disabled={!newTagInput.trim()}>
|
|
||||||
<Plus size={13} weight="bold" /> Add
|
|
||||||
</button>
|
|
||||||
<button class="s-btn" onclick={resetTags}>Reset</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,39 +99,114 @@
|
|||||||
<div class="s-section">
|
<div class="s-section">
|
||||||
<p class="s-section-title">Source Overrides</p>
|
<p class="s-section-title">Source Overrides</p>
|
||||||
<div class="s-section-body">
|
<div class="s-section-body">
|
||||||
<div class="s-row">
|
<label class="s-row">
|
||||||
<span class="s-desc">Allow lets a source through even if flagged NSFW. Block always hides it.</span>
|
<div class="s-row-info">
|
||||||
</div>
|
<span class="s-label">Per-source overrides</span>
|
||||||
<div class="s-search-wrap">
|
<span class="s-desc">Allow a source through even if flagged NSFW, or always block it. Allowed sources still respect the active content level.</span>
|
||||||
<input class="s-input full" placeholder="Filter sources…" bind:value={sourceSearch} />
|
|
||||||
</div>
|
|
||||||
{#if contentSourcesLoading}
|
|
||||||
<p class="s-empty">Loading sources…</p>
|
|
||||||
{:else if contentSources.length === 0}
|
|
||||||
<p class="s-empty">No sources found — check your server connection.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="s-source-list">
|
|
||||||
{#each contentSourcesFiltered as group (group.name)}
|
|
||||||
{@const ids = group.sources.map(s => s.id)}
|
|
||||||
{@const allowed = store.settings.nsfwAllowedSourceIds ?? []}
|
|
||||||
{@const blocked = store.settings.nsfwBlockedSourceIds ?? []}
|
|
||||||
{@const isAllowed = ids.every(id => allowed.includes(id))}
|
|
||||||
{@const isBlocked = ids.every(id => blocked.includes(id))}
|
|
||||||
<div class="s-source-row" class:allowed={isAllowed} class:blocked={isBlocked}>
|
|
||||||
<img src={thumbUrl(group.iconUrl)} alt="" class="s-source-icon" loading="lazy" decoding="async" />
|
|
||||||
<div class="s-source-info">
|
|
||||||
<span class="s-source-name">{group.name}</span>
|
|
||||||
<span class="s-source-meta">{group.sources[0].isNsfw ? "NSFW · " : ""}{group.sources.length > 1 ? `${group.sources.length} languages` : group.sources[0].lang.toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
<div class="s-source-actions">
|
|
||||||
<button class="s-source-action-btn" class:allow={isAllowed} onclick={() => toggleSourceAllowed(ids)}>Allow</button>
|
|
||||||
<button class="s-source-action-btn" class:block={isBlocked} onclick={() => toggleSourceBlocked(ids)}>Block</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
role="switch"
|
||||||
|
aria-checked={store.settings.sourceOverridesEnabled}
|
||||||
|
aria-label="Enable source overrides"
|
||||||
|
class="s-toggle"
|
||||||
|
class:on={store.settings.sourceOverridesEnabled}
|
||||||
|
onclick={() => updateSettings({ sourceOverridesEnabled: !store.settings.sourceOverridesEnabled })}
|
||||||
|
><span class="s-toggle-thumb"></span></button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if store.settings.sourceOverridesEnabled}
|
||||||
|
<div class="s-search-wrap">
|
||||||
|
<input class="s-input full" placeholder="Filter sources…" bind:value={sourceSearch} />
|
||||||
|
</div>
|
||||||
|
{#if contentSourcesLoading}
|
||||||
|
<p class="s-empty">Loading sources…</p>
|
||||||
|
{:else if contentSources.length === 0}
|
||||||
|
<p class="s-empty">No sources found — check your server connection.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="s-source-list">
|
||||||
|
{#each contentSourcesFiltered as group (group.name)}
|
||||||
|
{@const ids = group.sources.map(s => s.id)}
|
||||||
|
{@const allowed = store.settings.nsfwAllowedSourceIds ?? []}
|
||||||
|
{@const blocked = store.settings.nsfwBlockedSourceIds ?? []}
|
||||||
|
{@const isAllowed = ids.every(id => allowed.includes(id))}
|
||||||
|
{@const isBlocked = ids.every(id => blocked.includes(id))}
|
||||||
|
<div class="s-source-row" class:allowed={isAllowed} class:blocked={isBlocked}>
|
||||||
|
<img src={thumbUrl(group.iconUrl)} alt="" class="s-source-icon" loading="lazy" decoding="async" />
|
||||||
|
<div class="s-source-info">
|
||||||
|
<span class="s-source-name">{group.name}</span>
|
||||||
|
<span class="s-source-meta">
|
||||||
|
{group.sources[0].isNsfw ? "NSFW · " : ""}{group.sources.length > 1 ? `${group.sources.length} languages` : group.sources[0].lang.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="s-source-actions">
|
||||||
|
<button class="s-source-action-btn" class:allow={isAllowed} onclick={() => toggleSourceAllowed(ids)}>Allow</button>
|
||||||
|
<button class="s-source-action-btn" class:block={isBlocked} onclick={() => toggleSourceBlocked(ids)}>Block</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.s-level-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: var(--sp-2) var(--sp-4) var(--sp-3);
|
||||||
|
gap: var(--sp-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-level-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: 10px var(--sp-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-dim);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color var(--t-base), background var(--t-base);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.s-level-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||||
|
.s-level-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||||
|
|
||||||
|
.s-level-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid var(--border-strong);
|
||||||
|
background: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.s-level-dot.active { border-color: var(--accent); background: var(--accent); }
|
||||||
|
|
||||||
|
.s-level-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-level-label {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.s-level-btn.active .s-level-label { color: var(--accent-fg); }
|
||||||
|
|
||||||
|
.s-level-desc {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-faint);
|
||||||
|
letter-spacing: var(--tracking-wide);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
.s-level-btn.active .s-level-desc { color: var(--accent-fg); opacity: 0.7; }
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,7 @@ export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
|||||||
export type ReadingDirection = "ltr" | "rtl";
|
export type ReadingDirection = "ltr" | "rtl";
|
||||||
export type ChapterSortDir = "desc" | "asc";
|
export type ChapterSortDir = "desc" | "asc";
|
||||||
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
|
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
|
||||||
|
export type ContentLevel = "strict" | "moderate" | "unrestricted";
|
||||||
|
|
||||||
export type LibrarySortMode =
|
export type LibrarySortMode =
|
||||||
| "az" | "unreadCount" | "totalChapters"
|
| "az" | "unreadCount" | "totalChapters"
|
||||||
@@ -84,7 +85,9 @@ export interface Settings {
|
|||||||
offsetDoubleSpreads: boolean; preloadPages: number;
|
offsetDoubleSpreads: boolean; preloadPages: number;
|
||||||
autoMarkRead: boolean; autoNextChapter: boolean;
|
autoMarkRead: boolean; autoNextChapter: boolean;
|
||||||
libraryCropCovers: boolean; libraryPageSize: number;
|
libraryCropCovers: boolean; libraryPageSize: number;
|
||||||
showNsfw: boolean; discordRpc: boolean;
|
contentLevel: ContentLevel; sourceOverridesEnabled: boolean;
|
||||||
|
nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[];
|
||||||
|
discordRpc: boolean;
|
||||||
chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number;
|
chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number;
|
||||||
uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean;
|
uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean;
|
||||||
serverUrl: string; serverBinary: string; autoStartServer: boolean;
|
serverUrl: string; serverBinary: string; autoStartServer: boolean;
|
||||||
@@ -103,7 +106,6 @@ export interface Settings {
|
|||||||
appLockEnabled: boolean; appLockPin: string;
|
appLockEnabled: boolean; appLockPin: string;
|
||||||
customThemes: CustomTheme[]; hiddenCategoryIds: number[];
|
customThemes: CustomTheme[]; hiddenCategoryIds: number[];
|
||||||
defaultLibraryCategoryId: number | null; savedIsDefaultCategory: boolean;
|
defaultLibraryCategoryId: number | null; savedIsDefaultCategory: boolean;
|
||||||
nsfwFilteredTags: string[]; nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[];
|
|
||||||
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
||||||
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
||||||
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
|
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
|
||||||
@@ -126,7 +128,10 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
pageStyle: "longstrip", readingDirection: "ltr", fitMode: "width",
|
pageStyle: "longstrip", readingDirection: "ltr", fitMode: "width",
|
||||||
readerZoom: 1.0, pageGap: true, optimizeContrast: false, offsetDoubleSpreads: false,
|
readerZoom: 1.0, pageGap: true, optimizeContrast: false, offsetDoubleSpreads: false,
|
||||||
preloadPages: 3, autoMarkRead: true, autoNextChapter: true,
|
preloadPages: 3, autoMarkRead: true, autoNextChapter: true,
|
||||||
libraryCropCovers: true, libraryPageSize: 48, showNsfw: false, discordRpc: false,
|
libraryCropCovers: true, libraryPageSize: 48,
|
||||||
|
contentLevel: "strict", sourceOverridesEnabled: false,
|
||||||
|
nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [],
|
||||||
|
discordRpc: false,
|
||||||
chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25,
|
chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25,
|
||||||
uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true,
|
uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true,
|
||||||
serverUrl: "http://localhost:4567", serverBinary: "", autoStartServer: true,
|
serverUrl: "http://localhost:4567", serverBinary: "", autoStartServer: true,
|
||||||
@@ -144,8 +149,6 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
appLockEnabled: false, appLockPin: "",
|
appLockEnabled: false, appLockPin: "",
|
||||||
customThemes: [], hiddenCategoryIds: [], defaultLibraryCategoryId: null,
|
customThemes: [], hiddenCategoryIds: [], defaultLibraryCategoryId: null,
|
||||||
savedIsDefaultCategory: false,
|
savedIsDefaultCategory: false,
|
||||||
nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"],
|
|
||||||
nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [],
|
|
||||||
libraryTabSort: {}, libraryTabStatus: {}, libraryTabFilters: {},
|
libraryTabSort: {}, libraryTabStatus: {}, libraryTabFilters: {},
|
||||||
extraScanDirs: [], serverDownloadsPath: "", serverLocalSourcePath: "",
|
extraScanDirs: [], serverDownloadsPath: "", serverLocalSourcePath: "",
|
||||||
qolAnimations: true,
|
qolAnimations: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user