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:
@@ -49,7 +49,6 @@ export interface CachedManga {
|
||||
genreEnriched: boolean;
|
||||
}
|
||||
|
||||
|
||||
export const COMMON_GENRES = [
|
||||
"Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance",
|
||||
"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" },
|
||||
];
|
||||
|
||||
|
||||
export function buildTagFilter(
|
||||
tags: string[],
|
||||
mode: TagMode,
|
||||
@@ -90,13 +88,12 @@ export function buildTagFilter(
|
||||
return { and: [genrePart, statusPart] };
|
||||
}
|
||||
|
||||
|
||||
export function filterSourceCache(
|
||||
sourceCache: Map<number, CachedManga>,
|
||||
tags: string[],
|
||||
mode: TagMode,
|
||||
statuses: string[],
|
||||
settings: Pick<Settings, "showNsfw" | "nsfwFilteredTags" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||
settings: Pick<Settings, "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
|
||||
): CachedManga[] {
|
||||
return [...sourceCache.values()].filter((m) => {
|
||||
if (shouldHideNsfw(m as any, settings)) return false;
|
||||
@@ -118,7 +115,6 @@ export function filterSourceCache(
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function toCachedManga(
|
||||
m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string },
|
||||
srcId: string,
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<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 { gql, thumbUrl } from "@api/client";
|
||||
import { GET_SOURCES } from "@api/queries/index";
|
||||
import type { Source } from "../../lib/types";
|
||||
import type { ContentLevel } from "@types/settings";
|
||||
import type { Source } from "@types";
|
||||
|
||||
let contentSources: Source[] = $state([]);
|
||||
let contentSourcesLoading: boolean = $state(false);
|
||||
let newTagInput = $state("");
|
||||
let tagsRevealed = $state(false);
|
||||
let sourceSearch = $state("");
|
||||
|
||||
$effect(() => {
|
||||
if (contentSources.length === 0 && !contentSourcesLoading) loadContentSources();
|
||||
if (store.settings.sourceOverridesEnabled && contentSources.length === 0 && !contentSourcesLoading)
|
||||
loadContentSources();
|
||||
});
|
||||
|
||||
async function loadContentSources() {
|
||||
@@ -24,22 +23,6 @@
|
||||
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[]) {
|
||||
const allowed = store.settings.nsfwAllowedSourceIds ?? [];
|
||||
const blocked = store.settings.nsfwBlockedSourceIds ?? [];
|
||||
@@ -72,59 +55,43 @@
|
||||
|
||||
const contentSourcesFiltered = $derived.by(() => {
|
||||
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>();
|
||||
for (const s of filtered) {
|
||||
const key = s.name;
|
||||
if (!map.has(key)) map.set(key, { name: s.name, iconUrl: s.iconUrl, isNsfw: s.isNsfw, sources: [] });
|
||||
map.get(key)!.sources.push(s);
|
||||
if (!map.has(s.name)) map.set(s.name, { name: s.name, iconUrl: s.iconUrl, isNsfw: s.isNsfw, sources: [] });
|
||||
map.get(s.name)!.sources.push(s);
|
||||
}
|
||||
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>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<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">
|
||||
<label class="s-row">
|
||||
<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>
|
||||
<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 class="s-row" style="border-bottom: none; padding-bottom: 0;">
|
||||
<span class="s-desc">Controls what content is visible across library, search, and discover.</span>
|
||||
</div>
|
||||
{#if tagsRevealed}
|
||||
<div class="s-tag-grid">
|
||||
{#each (store.settings.nsfwFilteredTags ?? []) as tag}
|
||||
<span class="s-tag">
|
||||
<Tag size={10} weight="light" />
|
||||
{tag}
|
||||
<button class="s-tag-remove" onclick={() => removeTag(tag)} title="Remove tag">×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<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 class="s-level-group">
|
||||
{#each LEVELS as lvl}
|
||||
{@const active = store.settings.contentLevel === lvl.value}
|
||||
<button class="s-level-btn" class:active onclick={() => updateSettings({ contentLevel: lvl.value })}>
|
||||
<span class="s-level-dot" class:active></span>
|
||||
<div class="s-level-text">
|
||||
<span class="s-level-label">{lvl.label}</span>
|
||||
<span class="s-level-desc">{lvl.desc}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,39 +99,114 @@
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Source Overrides</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<span class="s-desc">Allow lets a source through even if flagged NSFW. Block always hides it.</span>
|
||||
</div>
|
||||
<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}
|
||||
<label class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Per-source overrides</span>
|
||||
<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>
|
||||
</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}
|
||||
</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>
|
||||
Reference in New Issue
Block a user