mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
212 lines
8.2 KiB
Svelte
212 lines
8.2 KiB
Svelte
<script lang="ts">
|
|
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
|
import { requestManager } from '$lib/request-manager'
|
|
import { platformService } from '$lib/platform-service'
|
|
import type { ContentLevel } from '$lib/types/settings'
|
|
import type { Source } from '$lib/types'
|
|
|
|
let contentSources = $state<Source[]>([])
|
|
let contentSourcesLoading = $state(false)
|
|
let sourceSearch = $state('')
|
|
|
|
$effect(() => {
|
|
if (settingsState.settings.sourceOverridesEnabled && contentSources.length === 0 && !contentSourcesLoading)
|
|
loadContentSources()
|
|
})
|
|
|
|
async function loadContentSources() {
|
|
contentSourcesLoading = true
|
|
try {
|
|
const d = await requestManager.extensions.getSources()
|
|
contentSources = d.filter(s => s.id !== '0')
|
|
} catch (e) { console.error(e) }
|
|
finally { contentSourcesLoading = false }
|
|
}
|
|
|
|
function toggleSourceAllowed(ids: string[]) {
|
|
const allowed = settingsState.settings.nsfwAllowedSourceIds ?? []
|
|
const blocked = settingsState.settings.nsfwBlockedSourceIds ?? []
|
|
const allAllowed = ids.every(id => allowed.includes(id))
|
|
if (allAllowed) {
|
|
updateSettings({ nsfwAllowedSourceIds: allowed.filter(x => !ids.includes(x)) })
|
|
} else {
|
|
updateSettings({
|
|
nsfwAllowedSourceIds: [...allowed.filter(x => !ids.includes(x)), ...ids],
|
|
nsfwBlockedSourceIds: blocked.filter(x => !ids.includes(x)),
|
|
})
|
|
}
|
|
}
|
|
|
|
function toggleSourceBlocked(ids: string[]) {
|
|
const allowed = settingsState.settings.nsfwAllowedSourceIds ?? []
|
|
const blocked = settingsState.settings.nsfwBlockedSourceIds ?? []
|
|
const allBlocked = ids.every(id => blocked.includes(id))
|
|
if (allBlocked) {
|
|
updateSettings({ nsfwBlockedSourceIds: blocked.filter(x => !ids.includes(x)) })
|
|
} else {
|
|
updateSettings({
|
|
nsfwBlockedSourceIds: [...blocked.filter(x => !ids.includes(x)), ...ids],
|
|
nsfwAllowedSourceIds: allowed.filter(x => !ids.includes(x)),
|
|
})
|
|
}
|
|
}
|
|
|
|
interface ContentSourceGroup { name: string; iconUrl: string; isNsfw: boolean; sources: Source[] }
|
|
|
|
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 map = new Map<string, ContentSourceGroup>()
|
|
for (const s of filtered) {
|
|
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 Level</p>
|
|
<div class="s-section-body">
|
|
<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>
|
|
<div class="s-level-group">
|
|
{#each LEVELS as lvl}
|
|
{@const active = settingsState.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>
|
|
|
|
<div class="s-section">
|
|
<p class="s-section-title">Source Overrides</p>
|
|
<div class="s-section-body">
|
|
<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={settingsState.settings.sourceOverridesEnabled}
|
|
aria-label="Enable source overrides"
|
|
class="s-toggle"
|
|
class:on={settingsState.settings.sourceOverridesEnabled}
|
|
onclick={() => updateSettings({ sourceOverridesEnabled: !settingsState.settings.sourceOverridesEnabled })}
|
|
><span class="s-toggle-thumb"></span></button>
|
|
</label>
|
|
|
|
{#if settingsState.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 = settingsState.settings.nsfwAllowedSourceIds ?? []}
|
|
{@const blocked = settingsState.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={platformService.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>
|
|
|
|
<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> |