mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
647 lines
34 KiB
Svelte
647 lines
34 KiB
Svelte
<script lang="ts">
|
||
import {
|
||
Download, CheckCircle, Circle, SortAscending, SortDescending,
|
||
CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus,
|
||
Trash, DownloadSimple, X, MagnifyingGlass, Funnel, Check, FolderOpen,
|
||
} from 'phosphor-svelte'
|
||
import type { Chapter, Category } from '$lib/types'
|
||
import type { ChapterSortMode, ChapterSortDir } from './lib/chapterList'
|
||
|
||
interface ContinueChapter {
|
||
chapter: Chapter
|
||
type: 'start' | 'continue' | 'reread'
|
||
resumePage: number | null
|
||
}
|
||
|
||
interface Props {
|
||
chapters: Chapter[]
|
||
sortedChapters: Chapter[]
|
||
sortMode: ChapterSortMode
|
||
sortDir: ChapterSortDir
|
||
viewMode: 'list' | 'grid'
|
||
downloadedCount: number
|
||
totalCount: number
|
||
deletingAll: boolean
|
||
hasSelection: boolean
|
||
selectedCount: number
|
||
continueChapter: ContinueChapter | null
|
||
availableScanlators: string[]
|
||
scanlatorFilter: string[]
|
||
scanlatorBlacklist: string[]
|
||
scanlatorForce: boolean
|
||
allCategories: Category[]
|
||
mangaCategories: Category[]
|
||
catsLoading: boolean
|
||
refreshing: boolean
|
||
onViewModeToggle: () => void
|
||
onPageChange: (page: number) => void
|
||
onDownloadSelected: () => void
|
||
onDeleteSelected: () => void
|
||
onMarkSelectedRead: (isRead: boolean) => void
|
||
onClearSelection: () => void
|
||
onEnqueueNext: (n: number) => void
|
||
onEnqueueMultiple: (ids: number[]) => void
|
||
onDeleteAll: () => void
|
||
onRefresh: () => void
|
||
onToggleCategory: (cat: Category) => void
|
||
onCreateCategory: (name: string) => void
|
||
onSetScanlatorFilter: (v: string[]) => void
|
||
onSetScanlatorBlacklist: (v: string[]) => void
|
||
onSetScanlatorForce: (v: boolean) => void
|
||
onOpenFolder: () => void
|
||
onSortModeChange: (v: ChapterSortMode) => void
|
||
onSortDirChange: (v: ChapterSortDir) => void
|
||
}
|
||
|
||
let {
|
||
chapters, sortedChapters, sortMode, sortDir, viewMode,
|
||
downloadedCount, totalCount, deletingAll,
|
||
hasSelection, selectedCount, continueChapter,
|
||
availableScanlators, scanlatorFilter, scanlatorBlacklist, scanlatorForce,
|
||
allCategories, mangaCategories, catsLoading, refreshing,
|
||
onViewModeToggle, onPageChange, onDownloadSelected, onDeleteSelected,
|
||
onMarkSelectedRead, onClearSelection, onEnqueueNext, onEnqueueMultiple,
|
||
onDeleteAll, onRefresh, onToggleCategory, onCreateCategory,
|
||
onSetScanlatorFilter, onSetScanlatorBlacklist, onSetScanlatorForce,
|
||
onOpenFolder, onSortModeChange, onSortDirChange,
|
||
}: Props = $props()
|
||
|
||
let sortMenuOpen: boolean = $state(false)
|
||
let jumpOpen: boolean = $state(false)
|
||
let jumpInput: string = $state('')
|
||
let scanFilterOpen: boolean = $state(false)
|
||
let scanTab: 'prefer' | 'block' = $state('prefer')
|
||
let dlOpen: boolean = $state(false)
|
||
let showRange: boolean = $state(false)
|
||
let rangeFrom: string = $state('')
|
||
let rangeTo: string = $state('')
|
||
let folderPickerOpen: boolean = $state(false)
|
||
let folderCreating: boolean = $state(false)
|
||
let folderNewName: string = $state('')
|
||
let dlDropRef: HTMLDivElement | undefined = $state()
|
||
let folderPickerRef: HTMLDivElement | undefined = $state()
|
||
|
||
const hasFolders = $derived(mangaCategories.filter(c => c.id !== 0).length > 0)
|
||
|
||
const jumpChapter = $derived.by(() => {
|
||
const q = jumpInput.trim().toLowerCase()
|
||
if (!q) return null
|
||
const num = parseFloat(q)
|
||
if (!isNaN(num)) return sortedChapters.find(c => c.chapterNumber === num) ?? null
|
||
return sortedChapters.find(c => c.name.toLowerCase().includes(q)) ?? null
|
||
})
|
||
|
||
function focusOnMount(node: HTMLElement) { node.focus() }
|
||
|
||
function doJump() {
|
||
if (!jumpChapter) return
|
||
const pageIdx = sortedChapters.indexOf(jumpChapter)
|
||
if (pageIdx >= 0) onPageChange(Math.floor(pageIdx / 25) + 1)
|
||
jumpOpen = false; jumpInput = ''
|
||
}
|
||
|
||
function enqueueRange() {
|
||
const from = parseFloat(rangeFrom), to = parseFloat(rangeTo)
|
||
if (isNaN(from) || isNaN(to)) return
|
||
const lo = Math.min(from, to), hi = Math.max(from, to)
|
||
onEnqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.downloaded).map(c => c.id))
|
||
}
|
||
|
||
function submitNewFolder() {
|
||
const name = folderNewName.trim()
|
||
if (!name) return
|
||
onCreateCategory(name)
|
||
folderNewName = ''; folderCreating = false
|
||
}
|
||
|
||
$effect(() => {
|
||
if (dlOpen) { setTimeout(() => document.addEventListener('mousedown', handleDlOutside, true), 0) }
|
||
else document.removeEventListener('mousedown', handleDlOutside, true)
|
||
})
|
||
|
||
$effect(() => {
|
||
if (folderPickerOpen) { setTimeout(() => document.addEventListener('mousedown', handleFolderOutside, true), 0) }
|
||
else document.removeEventListener('mousedown', handleFolderOutside, true)
|
||
})
|
||
|
||
$effect(() => {
|
||
if (!scanFilterOpen) return
|
||
function onOutside(e: MouseEvent) {
|
||
if (!(e.target as HTMLElement).closest('.scan-filter-wrap')) scanFilterOpen = false
|
||
}
|
||
setTimeout(() => document.addEventListener('mousedown', onOutside, true), 0)
|
||
return () => document.removeEventListener('mousedown', onOutside, true)
|
||
})
|
||
|
||
function handleDlOutside(e: MouseEvent) {
|
||
if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false
|
||
}
|
||
function handleFolderOutside(e: MouseEvent) {
|
||
if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) {
|
||
folderPickerOpen = false; folderCreating = false; folderNewName = ''
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<div class="list-header">
|
||
<div class="list-header-left">
|
||
{#if hasSelection}
|
||
<span class="sel-count">{selectedCount} selected</span>
|
||
<button class="sel-action-btn" onclick={onDownloadSelected} title="Download selected"><Download size={13} weight="light" /></button>
|
||
<button class="sel-action-btn sel-action-danger" onclick={onDeleteSelected} title="Delete selected downloads"><Trash size={13} weight="light" /></button>
|
||
<button class="sel-action-btn" onclick={() => onMarkSelectedRead(true)} title="Mark selected as read"><CheckCircle size={13} weight="light" /></button>
|
||
<button class="sel-action-btn" onclick={() => onMarkSelectedRead(false)} title="Mark selected as unread"><Circle size={13} weight="light" /></button>
|
||
<button class="sel-action-btn" onclick={onClearSelection} title="Clear selection"><X size={13} weight="light" /></button>
|
||
{:else}
|
||
<div class="sort-wrap">
|
||
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
|
||
{#if sortDir === 'desc'}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
|
||
{{ source: 'Source order', chapterNumber: 'Ch. number', uploadDate: 'Upload date' }[sortMode]}
|
||
<CaretDown size={10} weight="light" />
|
||
</button>
|
||
{#if sortMenuOpen}
|
||
<div class="sort-menu" role="presentation" onmouseleave={() => sortMenuOpen = false}>
|
||
{#each [['source','Source order'],['chapterNumber','Chapter number'],['uploadDate','Upload date']] as [val, label]}
|
||
<button
|
||
class="sort-option"
|
||
class:active={sortMode === val}
|
||
onclick={() => { onSortModeChange(val as ChapterSortMode); onPageChange(1); sortMenuOpen = false }}
|
||
>{label}</button>
|
||
{/each}
|
||
<div class="sort-divider"></div>
|
||
<button class="sort-option" onclick={() => { onSortDirChange(sortDir === 'desc' ? 'asc' : 'desc'); onPageChange(1); sortMenuOpen = false }}>
|
||
{sortDir === 'desc' ? '↑ Ascending' : '↓ Descending'}
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
<button class="icon-btn" class:active={viewMode === 'grid'} onclick={onViewModeToggle} title={viewMode === 'list' ? 'Grid view' : 'List view'}>
|
||
{#if viewMode === 'list'}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="list-header-right">
|
||
|
||
<div class="jump-wrap">
|
||
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = '' }} title="Jump to chapter">
|
||
<MagnifyingGlass size={14} weight="light" />
|
||
</button>
|
||
{#if jumpOpen}
|
||
<div class="jump-popover">
|
||
<input
|
||
class="jump-input"
|
||
placeholder="Chapter # or name…"
|
||
bind:value={jumpInput}
|
||
use:focusOnMount
|
||
onkeydown={(e) => { if (e.key === 'Enter') doJump(); if (e.key === 'Escape') { jumpOpen = false; jumpInput = '' } }}
|
||
/>
|
||
{#if jumpChapter}
|
||
<button class="jump-go" onclick={doJump}>Go · {jumpChapter.name}</button>
|
||
{:else if jumpInput.trim()}
|
||
<p class="jump-none">No match</p>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if availableScanlators.length > 1}
|
||
<div class="scan-filter-wrap">
|
||
<button
|
||
class="icon-btn"
|
||
class:active={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0}
|
||
onclick={() => scanFilterOpen = !scanFilterOpen}
|
||
title="Filter by scanlator"
|
||
>
|
||
<Funnel size={14} weight={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0 ? 'fill' : 'light'} />
|
||
</button>
|
||
{#if scanFilterOpen}
|
||
<div class="scan-filter-panel" role="menu">
|
||
<div class="scan-filter-header">
|
||
<div class="scan-filter-tabs">
|
||
<button class="scan-filter-tab" class:scan-filter-tab-active={scanTab === 'prefer'} onclick={() => scanTab = 'prefer'}>Prefer</button>
|
||
<button class="scan-filter-tab" class:scan-filter-tab-active={scanTab === 'block'} onclick={() => scanTab = 'block'}>Block</button>
|
||
</div>
|
||
{#if scanTab === 'prefer' && scanlatorFilter.length > 0}
|
||
<button class="scan-filter-clear" onclick={() => { onSetScanlatorFilter([]); onSetScanlatorForce(false); onPageChange(1) }}>Clear</button>
|
||
{:else if scanTab === 'block' && scanlatorBlacklist.length > 0}
|
||
<button class="scan-filter-clear" onclick={() => { onSetScanlatorBlacklist([]); onPageChange(1) }}>Clear</button>
|
||
{/if}
|
||
</div>
|
||
<div class="scan-filter-divider"></div>
|
||
{#if scanTab === 'prefer'}
|
||
<div class="scan-filter-force-row">
|
||
<span class="scan-filter-force-label" title="Hide chapters with no preferred group match, rather than falling back to any available group.">Enforce</span>
|
||
<button class="scan-force-toggle" class:scan-force-on={scanlatorForce} onclick={() => { onSetScanlatorForce(!scanlatorForce); onPageChange(1) }}>
|
||
{scanlatorForce ? 'On' : 'Off'}
|
||
</button>
|
||
</div>
|
||
<div class="scan-filter-divider"></div>
|
||
{#each availableScanlators as s}
|
||
<button
|
||
class="scan-filter-item"
|
||
class:scan-filter-item-active={scanlatorFilter.includes(s)}
|
||
role="menuitem"
|
||
onclick={() => { onSetScanlatorFilter(scanlatorFilter.includes(s) ? scanlatorFilter.filter(x => x !== s) : [...scanlatorFilter, s]); onPageChange(1) }}
|
||
>
|
||
<span class="scan-filter-check" class:scan-filter-check-on={scanlatorFilter.includes(s)}>
|
||
{#if scanlatorFilter.includes(s)}<Check size={9} weight="bold" />{/if}
|
||
</span>
|
||
{s}
|
||
</button>
|
||
{/each}
|
||
{:else}
|
||
{#each availableScanlators as s}
|
||
<button
|
||
class="scan-filter-item"
|
||
class:scan-filter-item-active={scanlatorBlacklist.includes(s)}
|
||
class:scan-filter-item-block={scanlatorBlacklist.includes(s)}
|
||
role="menuitem"
|
||
onclick={() => { onSetScanlatorBlacklist(scanlatorBlacklist.includes(s) ? scanlatorBlacklist.filter(x => x !== s) : [...scanlatorBlacklist, s]); onPageChange(1) }}
|
||
>
|
||
<span class="scan-filter-check" class:scan-filter-check-block={scanlatorBlacklist.includes(s)}>
|
||
{#if scanlatorBlacklist.includes(s)}<X size={9} weight="bold" />{/if}
|
||
</span>
|
||
{s}
|
||
</button>
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<button class="icon-btn" onclick={onRefresh} disabled={refreshing} title="Refresh chapters">
|
||
<ArrowsClockwise size={14} weight="light" class={refreshing ? 'anim-spin' : ''} />
|
||
</button>
|
||
|
||
{#if downloadedCount > 0}
|
||
<button class="icon-btn" onclick={onOpenFolder} title="Open manga folder">
|
||
<FolderOpen size={14} weight="light" />
|
||
</button>
|
||
{/if}
|
||
|
||
<div class="fp-wrap" bind:this={folderPickerRef}>
|
||
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
||
<FolderSimplePlus size={14} weight={hasFolders ? 'fill' : 'light'} />
|
||
</button>
|
||
{#if folderPickerOpen}
|
||
<div class="fp-menu">
|
||
{#if catsLoading}
|
||
<p class="fp-empty">Loading…</p>
|
||
{:else if allCategories.length === 0 && !folderCreating}
|
||
<p class="fp-empty">No folders yet</p>
|
||
{/if}
|
||
{#each allCategories as cat}
|
||
{@const isIn = mangaCategories.some(c => c.id === cat.id)}
|
||
<button class="fp-item" class:fp-item-active={isIn} onclick={() => onToggleCategory(cat)}>
|
||
<span class="fp-check">{isIn ? '✓' : ''}</span>{cat.name}
|
||
</button>
|
||
{/each}
|
||
<div class="fp-div"></div>
|
||
{#if folderCreating}
|
||
<div class="fp-create">
|
||
<input
|
||
class="fp-input"
|
||
placeholder="Folder name…"
|
||
bind:value={folderNewName}
|
||
use:focusOnMount
|
||
onkeydown={(e) => { if (e.key === 'Enter') submitNewFolder(); if (e.key === 'Escape') { folderCreating = false; folderNewName = '' } }}
|
||
/>
|
||
<button class="fp-confirm" onclick={submitNewFolder} disabled={!folderNewName.trim()}>Add</button>
|
||
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = '' }}><X size={12} weight="light" /></button>
|
||
</div>
|
||
{:else}
|
||
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if chapters.length > 0}
|
||
<div class="dl-wrap" bind:this={dlDropRef}>
|
||
<button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
|
||
<Download size={13} weight={downloadedCount > 0 ? 'fill' : 'light'} />
|
||
{#if downloadedCount > 0}<span class="dl-unified-count">{downloadedCount}</span>{/if}
|
||
</button>
|
||
{#if dlOpen}
|
||
<div class="dl-dropdown">
|
||
{#if downloadedCount > 0}
|
||
<p class="dl-section-label">{downloadedCount} / {totalCount} downloaded</p>
|
||
<div class="dl-divider"></div>
|
||
{/if}
|
||
{#if continueChapter}
|
||
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
|
||
{#if contIdx >= 0}
|
||
<p class="dl-section-label">From Ch.{continueChapter.chapter.chapterNumber}</p>
|
||
<div class="dl-next-row">
|
||
{#each [5, 10, 25] as n}
|
||
{@const avail = sortedChapters.slice(contIdx, contIdx + n).filter(c => !c.downloaded).length}
|
||
<button class="dl-next-btn" disabled={avail === 0} onclick={() => { onEnqueueNext(n); dlOpen = false }}>
|
||
<span>Next {n}</span><span class="dl-next-sub">{avail} new</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
<div class="dl-divider"></div>
|
||
{/if}
|
||
{/if}
|
||
{#if !showRange}
|
||
<button class="dl-item" onclick={() => showRange = true}>
|
||
<span>Custom range…</span><span class="dl-item-sub">Enter chapter numbers</span>
|
||
</button>
|
||
{:else}
|
||
<div class="dl-range-row">
|
||
<button class="dl-range-back" onclick={() => showRange = false}>‹</button>
|
||
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === 'Enter' && enqueueRange()} use:focusOnMount />
|
||
<span class="dl-range-sep">–</span>
|
||
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === 'Enter' && enqueueRange()} />
|
||
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
|
||
</div>
|
||
{/if}
|
||
<div class="dl-divider"></div>
|
||
<button class="dl-item" onclick={() => { onEnqueueMultiple(sortedChapters.filter(c => !c.read && !c.downloaded).map(c => c.id)); dlOpen = false }}>
|
||
<span>Unread chapters</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.read && !c.downloaded).length} remaining</span>
|
||
</button>
|
||
<button class="dl-item" onclick={() => { onEnqueueMultiple(sortedChapters.filter(c => !c.downloaded).map(c => c.id)); dlOpen = false }}>
|
||
<span>Download all</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.downloaded).length} not downloaded</span>
|
||
</button>
|
||
{#if downloadedCount > 0}
|
||
<div class="dl-divider"></div>
|
||
<button class="dl-item dl-item-danger" onclick={() => { onDeleteAll(); dlOpen = false }} disabled={deletingAll}>
|
||
<span>{deletingAll ? 'Deleting…' : 'Delete all downloads'}</span>
|
||
<span class="dl-item-sub">{downloadedCount} downloaded</span>
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.list-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim);
|
||
flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap;
|
||
}
|
||
.list-header-left,
|
||
.list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
||
|
||
.sort-btn {
|
||
display: flex; align-items: center; gap: 5px;
|
||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
|
||
color: var(--text-muted); transition: color var(--t-base), background var(--t-base);
|
||
}
|
||
.sort-btn:hover { color: var(--text-secondary); background: var(--bg-raised); }
|
||
.sort-wrap { position: relative; }
|
||
.sort-menu {
|
||
position: absolute; top: calc(100% + 4px); left: 0; min-width: 160px;
|
||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||
border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||
animation: scaleIn 0.1s ease both; transform-origin: top left;
|
||
}
|
||
.sort-option {
|
||
display: block; width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary);
|
||
background: none; border: none; cursor: pointer; text-align: left;
|
||
transition: background var(--t-fast), color var(--t-fast);
|
||
}
|
||
.sort-option:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||
.sort-option.active { color: var(--accent-fg); }
|
||
.sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||
|
||
.icon-btn {
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||
border: 1px solid var(--border-dim); color: var(--text-muted);
|
||
background: none; cursor: pointer;
|
||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||
}
|
||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||
|
||
.jump-wrap { position: relative; }
|
||
.jump-popover {
|
||
position: absolute; top: calc(100% + 4px); right: 0; width: 220px;
|
||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||
border-radius: var(--radius-md); padding: var(--sp-2); z-index: 200;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||
animation: scaleIn 0.1s ease both; transform-origin: top right;
|
||
display: flex; flex-direction: column; gap: var(--sp-1);
|
||
}
|
||
.jump-input {
|
||
width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-sm); padding: 5px 9px;
|
||
font-size: var(--text-xs); color: var(--text-secondary); outline: none;
|
||
transition: border-color var(--t-base);
|
||
}
|
||
.jump-input:focus { border-color: var(--border-focus); }
|
||
.jump-go {
|
||
width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-sm);
|
||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||
cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
transition: background var(--t-fast), border-color var(--t-fast);
|
||
}
|
||
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
|
||
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
|
||
|
||
.fp-wrap { position: relative; }
|
||
.fp-menu {
|
||
position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px;
|
||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||
border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||
animation: scaleIn 0.1s ease both; transform-origin: top right;
|
||
}
|
||
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
|
||
.fp-item {
|
||
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
|
||
padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs);
|
||
color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left;
|
||
transition: background var(--t-fast), color var(--t-fast);
|
||
}
|
||
.fp-item:hover { background: var(--bg-overlay); }
|
||
.fp-item.fp-item-active { color: var(--accent-fg); }
|
||
.fp-check { width: 12px; font-size: var(--text-xs); color: var(--accent-fg); flex-shrink: 0; }
|
||
.fp-div { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||
.fp-create { display: flex; align-items: center; gap: var(--sp-1); padding: 4px var(--sp-2); }
|
||
.fp-input {
|
||
flex: 1; background: var(--bg-overlay); border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-sm); padding: 4px 8px;
|
||
font-size: var(--text-xs); color: var(--text-secondary); outline: none; min-width: 0;
|
||
}
|
||
.fp-input:focus { border-color: var(--border-focus); }
|
||
.fp-confirm {
|
||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||
padding: 4px 8px; border-radius: var(--radius-sm);
|
||
border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer;
|
||
}
|
||
.fp-confirm:disabled { opacity: 0.4; cursor: default; }
|
||
.fp-cancel {
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 22px; height: 22px; border-radius: var(--radius-sm);
|
||
border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer;
|
||
transition: color var(--t-base), border-color var(--t-base);
|
||
}
|
||
.fp-cancel:hover { color: var(--text-muted); border-color: var(--border-dim); }
|
||
.fp-new {
|
||
width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm);
|
||
font-size: var(--text-xs); color: var(--text-faint); background: none; border: none;
|
||
cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast);
|
||
}
|
||
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
||
|
||
.dl-wrap { position: relative; }
|
||
.dl-dropdown {
|
||
position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px;
|
||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||
border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||
animation: scaleIn 0.1s ease both; transform-origin: top right;
|
||
}
|
||
.dl-section-label {
|
||
padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs);
|
||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||
}
|
||
.dl-next-row { display: flex; gap: 4px; padding: 2px var(--sp-2) var(--sp-2); }
|
||
.dl-next-btn {
|
||
flex: 1; display: flex; align-items: center; justify-content: center; gap: 5px;
|
||
padding: 5px 6px; border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
||
background: var(--bg-overlay); color: var(--text-secondary);
|
||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer;
|
||
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
|
||
}
|
||
.dl-next-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||
.dl-next-btn:disabled { opacity: 0.3; cursor: default; }
|
||
.dl-next-sub { font-size: var(--text-2xs); color: var(--text-faint); }
|
||
.dl-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
|
||
.dl-item {
|
||
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
|
||
width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md);
|
||
font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none;
|
||
cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast);
|
||
}
|
||
.dl-item:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
|
||
.dl-item:disabled { opacity: 0.3; cursor: default; }
|
||
.dl-item.dl-item-danger { color: var(--color-error); }
|
||
.dl-item.dl-item-danger:hover:not(:disabled) { background: var(--color-error-bg); }
|
||
.dl-item-sub { font-size: var(--text-xs); color: var(--text-faint); }
|
||
.dl-range-row { display: flex; align-items: center; gap: 4px; padding: 7px var(--sp-2); }
|
||
.dl-range-back {
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 20px; height: 20px; border-radius: var(--radius-sm);
|
||
border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 14px; cursor: pointer;
|
||
}
|
||
.dl-range-back:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||
.dl-range-input {
|
||
flex: 1; min-width: 0; padding: 4px 8px; background: var(--bg-overlay);
|
||
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
|
||
outline: none; text-align: center;
|
||
}
|
||
.dl-range-input:focus { border-color: var(--border-focus); }
|
||
.dl-range-sep { color: var(--text-faint); font-size: var(--text-xs); }
|
||
.dl-range-go {
|
||
padding: 4px 10px; border-radius: var(--radius-sm);
|
||
border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg);
|
||
font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer;
|
||
}
|
||
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
|
||
.dl-unified-btn { gap: 5px; padding: 0 8px; width: auto; min-width: 28px; }
|
||
.dl-unified-count {
|
||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||
color: var(--text-faint); transition: color var(--t-base);
|
||
}
|
||
.dl-unified-btn:hover .dl-unified-count,
|
||
.dl-unified-btn.active .dl-unified-count { color: var(--text-secondary); }
|
||
.dl-unified-btn.dl-has-count { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||
.dl-unified-btn.dl-has-count .dl-unified-count { color: var(--accent-fg); opacity: 0.8; }
|
||
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
|
||
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||
|
||
.sel-count {
|
||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
|
||
letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1);
|
||
}
|
||
.sel-action-btn {
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
||
border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer;
|
||
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
|
||
}
|
||
.sel-action-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
|
||
.sel-action-danger { color: var(--color-error) !important; }
|
||
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
|
||
|
||
.scan-filter-wrap { position: relative; }
|
||
.scan-filter-panel {
|
||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; min-width: 200px;
|
||
background: var(--bg-raised); border: 1px solid var(--border-base);
|
||
border-radius: var(--radius-lg); padding: var(--sp-1);
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||
animation: scaleIn 0.1s ease both; transform-origin: top right;
|
||
}
|
||
.scan-filter-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px 6px; }
|
||
.scan-filter-clear {
|
||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||
color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0;
|
||
transition: color var(--t-base);
|
||
}
|
||
.scan-filter-clear:hover { color: var(--color-error); }
|
||
.scan-filter-divider { height: 1px; background: var(--border-dim); margin: 0 2px 4px; }
|
||
.scan-filter-item {
|
||
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
|
||
padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent;
|
||
color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs);
|
||
cursor: pointer; text-align: left;
|
||
transition: background var(--t-base), color var(--t-base);
|
||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.scan-filter-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||
.scan-filter-item-active { color: var(--accent-fg); background: var(--accent-muted); }
|
||
.scan-filter-item-active:hover { background: var(--accent-dim); }
|
||
.scan-filter-tabs {
|
||
display: flex; gap: 2px; background: var(--bg-overlay);
|
||
border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px;
|
||
}
|
||
.scan-filter-tab {
|
||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||
padding: 2px 8px; border-radius: 2px; border: none; background: none;
|
||
color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast);
|
||
}
|
||
.scan-filter-tab:hover { color: var(--text-muted); }
|
||
.scan-filter-tab.scan-filter-tab-active { background: var(--bg-surface); color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||
.scan-filter-force-row { display: flex; align-items: center; justify-content: space-between; padding: 5px 10px; }
|
||
.scan-filter-force-label {
|
||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
|
||
letter-spacing: var(--tracking-wide); cursor: default;
|
||
text-decoration: underline; text-decoration-style: dotted;
|
||
text-decoration-color: var(--border-strong); text-underline-offset: 3px;
|
||
}
|
||
.scan-force-toggle {
|
||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||
padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||
background: none; color: var(--text-faint); cursor: pointer;
|
||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||
}
|
||
.scan-force-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||
.scan-force-toggle.scan-force-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||
.scan-filter-check {
|
||
width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong);
|
||
background: transparent; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center; color: var(--bg-base);
|
||
transition: background var(--t-base), border-color var(--t-base);
|
||
}
|
||
.scan-filter-check-on { background: var(--accent); border-color: var(--accent); }
|
||
.scan-filter-check-block { background: var(--color-error); border-color: var(--color-error); }
|
||
.scan-filter-item-block { color: var(--color-error) !important; background: color-mix(in srgb, var(--color-error) 8%, transparent) !important; }
|
||
.scan-filter-item-block:hover { background: color-mix(in srgb, var(--color-error) 14%, transparent) !important; }
|
||
|
||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||
</style> |