mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-14 09:49:58 -05:00
Chore: Port over Extensions & Search
This commit is contained in:
@@ -0,0 +1,475 @@
|
||||
<script lang="ts">
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { libraryState } from '$lib/state/library.svelte'
|
||||
import type { LibrarySortOption, LibraryContentFilter, LibraryStatusFilter } from '$lib/state/library.svelte'
|
||||
import { startLibraryUpdate } from '$lib/components/library/lib/libraryUpdater'
|
||||
import { addToast } from '$lib/state/notifications.svelte'
|
||||
import { updateSettings, settingsState } from '$lib/state/settings.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import LibraryToolbar from '$lib/components/library/LibraryToolbar.svelte'
|
||||
import LibraryGrid from '$lib/components/library/LibraryGrid.svelte'
|
||||
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
|
||||
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
|
||||
import type { Manga, Category } from '$lib/types'
|
||||
import {
|
||||
Books, Folder, FolderSimple, FolderSimplePlus,
|
||||
Trash, CheckSquare, ArrowSquareOut, ArrowsClockwise,
|
||||
} from 'phosphor-svelte'
|
||||
|
||||
const SIDEBAR_W = 52
|
||||
const TITLEBAR_H = 36
|
||||
const CTX_FOLDER_CAP = 4
|
||||
const DT_TAB = 'application/x-moku-tab'
|
||||
const COMPLETED_NAME = 'Completed'
|
||||
|
||||
let cancelUpdate: (() => void) | null = null
|
||||
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = $state(null)
|
||||
let emptyCtx: { x: number; y: number } | null = $state(null)
|
||||
|
||||
let bulkWorking: boolean = $state(false)
|
||||
let activeDragKind: 'tab' | null = $state(null)
|
||||
let dragInsertIdx = $state(-1)
|
||||
let dragTabId: string|null = $state(null)
|
||||
let dragOverTabId: string|null = $state(null)
|
||||
|
||||
$effect(() => {
|
||||
libraryState.syncFromSettings(settingsState.settings)
|
||||
loadLibrary()
|
||||
})
|
||||
$effect(() => { libraryState.syncFromSettings(settingsState.settings) })
|
||||
$effect(() => { libraryState.tab; libraryState.exitSelect() })
|
||||
$effect(() => { libraryState.guardTab() })
|
||||
|
||||
async function loadLibrary() {
|
||||
libraryState.loading = true
|
||||
libraryState.error = null
|
||||
try {
|
||||
const result = await getAdapter().getMangaList({ inLibrary: true })
|
||||
libraryState.items = result.items
|
||||
await loadCategories()
|
||||
} catch (e) {
|
||||
libraryState.error = String(e)
|
||||
} finally {
|
||||
libraryState.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
let cats = await getAdapter().getCategories()
|
||||
|
||||
if (!cats.some(c => c.name === COMPLETED_NAME)) {
|
||||
try {
|
||||
const created = await getAdapter().createCategory(COMPLETED_NAME)
|
||||
cats = [...cats, created]
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const needsPopulation = cats.every(c => !(c as any).mangas?.nodes?.length)
|
||||
if (needsPopulation && libraryState.items.length > 0) {
|
||||
cats = cats.map(c => {
|
||||
const nodes = libraryState.items.filter(m =>
|
||||
(m as any).categories?.nodes?.some((mc: any) => mc.id === c.id) ||
|
||||
(m as any).categoryIds?.includes(c.id)
|
||||
)
|
||||
return { ...c, mangas: { nodes } }
|
||||
})
|
||||
}
|
||||
|
||||
libraryState.setCategories(cats)
|
||||
} catch (e) {
|
||||
libraryState.error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function onCardClick(e: MouseEvent, m: Manga) {
|
||||
if (libraryState.selectMode) { libraryState.toggleSelect(m.id); return }
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); libraryState.enterSelect(m.id); return }
|
||||
goto(`/series/${m.id}`)
|
||||
}
|
||||
|
||||
function openCtx(e: MouseEvent, m: Manga) {
|
||||
if (libraryState.selectMode) { libraryState.toggleSelect(m.id); return }
|
||||
e.preventDefault()
|
||||
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m }
|
||||
}
|
||||
|
||||
async function doRemove(m: Manga) {
|
||||
await getAdapter().removeFromLibrary(String(m.id))
|
||||
libraryState.items = libraryState.items.filter(x => x.id !== m.id)
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
async function doDeleteDownloads(m: Manga) {
|
||||
try {
|
||||
const chapters = await getAdapter().getChapters(String(m.id))
|
||||
const downloaded = chapters.filter(c => c.downloaded).map(c => String(c.id))
|
||||
if (!downloaded.length) return
|
||||
await getAdapter().deleteDownloadedChapters(downloaded)
|
||||
libraryState.items = libraryState.items.map(x =>
|
||||
x.id === m.id ? { ...x, downloadCount: 0 } : x
|
||||
)
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function openMangaFolder(m: Manga) {
|
||||
let base: string | undefined
|
||||
try { base = await invoke<string>('get_default_downloads_path') } catch {}
|
||||
if (!base) { addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' }); return }
|
||||
const sanitize = (s: string) => s.replace(/[\/\\?%*:|"<>]/g, '_')
|
||||
const source = (m as any).source?.displayName ?? (m as any).source?.name ?? ''
|
||||
const path = source
|
||||
? `${base}/mangas/${sanitize(source)}/${sanitize(m.title)}`
|
||||
: `${base}/mangas/${sanitize(m.title)}`
|
||||
try { await invoke('open_path', { path }) }
|
||||
catch (e: any) { addToast({ kind: 'error', title: 'Could not open folder', body: e?.toString?.() ?? path }) }
|
||||
}
|
||||
|
||||
async function openDownloadsFolder() {
|
||||
let path: string | undefined
|
||||
try { path = await invoke<string>('get_default_downloads_path') } catch {}
|
||||
if (!path) { addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' }); return }
|
||||
try { await invoke('open_path', { path }) }
|
||||
catch (e: any) { addToast({ kind: 'error', title: 'Could not open folder', body: e?.toString?.() ?? path }) }
|
||||
}
|
||||
|
||||
async function refreshSingleManga(m: Manga) {
|
||||
if (libraryState.refreshingMangaId !== null) return
|
||||
libraryState.refreshingMangaId = m.id
|
||||
try {
|
||||
await getAdapter().fetchManga(String(m.id))
|
||||
await loadLibrary()
|
||||
addToast({ kind: 'success', title: 'Manga refreshed', body: m.title })
|
||||
} catch (e) {
|
||||
addToast({ kind: 'error', title: 'Refresh failed', body: String(e) })
|
||||
} finally {
|
||||
libraryState.refreshingMangaId = null
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAndMarkCompleted(mangaId: number) {
|
||||
const completedCat = libraryState.categories.find(c => c.name === COMPLETED_NAME)
|
||||
if (!completedCat) return
|
||||
const alreadyIn = (libraryState.categoryMangaMap.get(completedCat.id) ?? []).some(m => m.id === mangaId)
|
||||
if (alreadyIn) return
|
||||
try {
|
||||
await getAdapter().updateMangaCategories(String(mangaId), [completedCat.id], [])
|
||||
await loadCategories()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function toggleMangaCategory(manga: Manga, cat: Category) {
|
||||
const nodes = (cat as any).mangas?.nodes ?? libraryState.categoryMangaMap.get(cat.id) ?? []
|
||||
const inCat = nodes.some((m: Manga) => m.id === manga.id)
|
||||
libraryState.setCategories(
|
||||
libraryState.categories.map(c => {
|
||||
if (c.id !== cat.id) return c
|
||||
const existing = (c as any).mangas?.nodes ?? []
|
||||
const updated = inCat
|
||||
? existing.filter((m: Manga) => m.id !== manga.id)
|
||||
: [...existing, manga]
|
||||
return { ...c, mangas: { nodes: updated } }
|
||||
})
|
||||
)
|
||||
if (!inCat) libraryState.bumpCategoryFrecency(cat.id)
|
||||
try {
|
||||
await getAdapter().updateMangaCategories(String(manga.id), inCat ? [] : [cat.id], inCat ? [cat.id] : [])
|
||||
} catch {}
|
||||
await loadCategories()
|
||||
}
|
||||
|
||||
async function createAndAssign(manga: Manga) {
|
||||
const name = prompt('Folder name:')
|
||||
if (!name?.trim()) return
|
||||
try {
|
||||
const cat = await getAdapter().createCategory(name.trim())
|
||||
libraryState.setCategories([...libraryState.categories, cat])
|
||||
await getAdapter().updateMangaCategories(String(manga.id), [cat.id], [])
|
||||
libraryState.bumpCategoryFrecency(cat.id)
|
||||
await loadCategories()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function bulkMove(cat: Category) {
|
||||
bulkWorking = true
|
||||
try {
|
||||
await getAdapter().updateMangasCategories(
|
||||
[...libraryState.selected].map(String),
|
||||
[cat.id],
|
||||
[],
|
||||
)
|
||||
await loadCategories()
|
||||
} catch (e) { console.error(e) }
|
||||
finally { bulkWorking = false; libraryState.exitSelect() }
|
||||
}
|
||||
|
||||
async function onBulkRemove() {
|
||||
bulkWorking = true
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
[...libraryState.selected].map(id => getAdapter().removeFromLibrary(String(id)))
|
||||
)
|
||||
libraryState.items = libraryState.items.filter(m => !libraryState.selected.has(m.id))
|
||||
libraryState.exitSelect()
|
||||
} finally { bulkWorking = false }
|
||||
}
|
||||
|
||||
async function startRefresh() {
|
||||
if (libraryState.refreshing) return
|
||||
libraryState.refreshing = true
|
||||
libraryState.refreshProgress = { finished: 0, total: 0 }
|
||||
|
||||
cancelUpdate = startLibraryUpdate({
|
||||
onProgress(p) { libraryState.refreshProgress = p },
|
||||
async onDone({ newChapters, totalUpdated }) {
|
||||
cancelUpdate = null
|
||||
await loadLibrary()
|
||||
libraryState.refreshing = false
|
||||
libraryState.refreshDone = true
|
||||
if (refreshDoneTimer) clearTimeout(refreshDoneTimer)
|
||||
refreshDoneTimer = setTimeout(() => { libraryState.refreshDone = false }, 2500)
|
||||
if (newChapters > 0) {
|
||||
addToast({ kind: 'success', title: 'Library updated', body: `${newChapters} new chapter${newChapters !== 1 ? 's' : ''} across ${totalUpdated} series` })
|
||||
} else {
|
||||
addToast({ kind: 'info', title: 'Already up to date' })
|
||||
}
|
||||
},
|
||||
onError() { libraryState.refreshing = false; cancelUpdate = null },
|
||||
})
|
||||
}
|
||||
|
||||
async function cancelRefresh() {
|
||||
if (!libraryState.refreshing) return
|
||||
cancelUpdate?.(); cancelUpdate = null
|
||||
try { await getAdapter().stopLibraryUpdate() } catch {}
|
||||
libraryState.refreshing = false
|
||||
libraryState.refreshProgress = { finished: 0, total: 0 }
|
||||
}
|
||||
|
||||
async function refreshCategory(catId: number) {
|
||||
if (libraryState.refreshingCatId !== null || libraryState.refreshing) return
|
||||
libraryState.refreshingCatId = catId
|
||||
try {
|
||||
await getAdapter().updateCategoryManga(catId)
|
||||
await loadLibrary()
|
||||
const cat = libraryState.categories.find(c => c.id === catId)
|
||||
addToast({ kind: 'success', title: 'Folder refreshed', body: cat?.name ?? '' })
|
||||
} catch (e) {
|
||||
addToast({ kind: 'error', title: 'Refresh failed', body: String(e) })
|
||||
} finally { libraryState.refreshingCatId = null }
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
const sorted = [...libraryState.visibleCategories].sort(
|
||||
(a, b) => (libraryState.categoryFrecency[b.id] ?? 0) - (libraryState.categoryFrecency[a.id] ?? 0)
|
||||
)
|
||||
const pinned = sorted.slice(0, CTX_FOLDER_CAP)
|
||||
const overflow = sorted.slice(CTX_FOLDER_CAP)
|
||||
|
||||
const makeCatEntry = (cat: Category): MenuEntry => {
|
||||
const inCat = (libraryState.categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id)
|
||||
return { label: inCat ? `Remove from ${cat.name}` : cat.name, icon: Folder, onClick: () => toggleMangaCategory(m, cat) }
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: m.inLibrary ? 'Remove from library' : 'Add to library', icon: Books,
|
||||
onClick: () => m.inLibrary
|
||||
? doRemove(m)
|
||||
: getAdapter().addToLibrary(String(m.id)).then(loadLibrary).catch(console.error) },
|
||||
{ label: libraryState.refreshingMangaId === m.id ? 'Refreshing…' : 'Refresh manga', icon: ArrowsClockwise,
|
||||
disabled: libraryState.refreshingMangaId !== null, onClick: () => refreshSingleManga(m) },
|
||||
{ label: 'Open in file manager', icon: ArrowSquareOut,
|
||||
disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => openMangaFolder(m) },
|
||||
{ label: 'Delete all downloads', icon: Trash, danger: true,
|
||||
disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => doDeleteDownloads(m) },
|
||||
{ separator: true },
|
||||
{ label: 'Select', icon: CheckSquare, onClick: () => libraryState.enterSelect(m.id) },
|
||||
...(pinned.length ? [{ separator: true } as MenuEntry, ...pinned.map(makeCatEntry)] : []),
|
||||
...(overflow.length ? [{ label: `More folders (${overflow.length})`, icon: FolderSimple, onClick: () => {}, children: overflow.map(makeCatEntry) } as MenuEntry] : []),
|
||||
{ separator: true },
|
||||
{ label: 'New folder', icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
|
||||
]
|
||||
}
|
||||
|
||||
function buildEmptyCtx(): MenuEntry[] {
|
||||
return [{
|
||||
label: 'New folder', icon: FolderSimplePlus,
|
||||
onClick: async () => {
|
||||
const name = prompt('Folder name:')
|
||||
if (!name?.trim()) return
|
||||
try {
|
||||
const cat = await getAdapter().createCategory(name.trim())
|
||||
libraryState.setCategories([...libraryState.categories, cat])
|
||||
} catch (e) { console.error(e) }
|
||||
},
|
||||
}]
|
||||
}
|
||||
|
||||
function onTabDragStart(e: DragEvent, id: string) {
|
||||
activeDragKind = 'tab'; dragTabId = id
|
||||
e.dataTransfer!.effectAllowed = 'move'
|
||||
e.dataTransfer!.setData(DT_TAB, id)
|
||||
e.dataTransfer!.setData('text/plain', `tab:${id}`)
|
||||
}
|
||||
|
||||
function onTabDragOver(e: DragEvent, id: string, idx: number) {
|
||||
if (activeDragKind !== 'tab' || dragTabId === null || dragTabId === id) return
|
||||
e.preventDefault(); e.dataTransfer!.dropEffect = 'move'
|
||||
dragOverTabId = id
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1
|
||||
}
|
||||
|
||||
function onTabDragLeave() { dragOverTabId = null }
|
||||
|
||||
async function onTabDrop(e: DragEvent, dropId: string) {
|
||||
e.preventDefault(); dragOverTabId = null
|
||||
const insertAt = dragInsertIdx; dragInsertIdx = -1
|
||||
if (activeDragKind !== 'tab' || dragTabId === null || dragTabId === dropId) { dragTabId = null; return }
|
||||
const dragStrId = dragTabId; dragTabId = null; activeDragKind = null
|
||||
|
||||
const tabs = [...libraryState.allTabIds]
|
||||
const fromIdx = tabs.indexOf(dragStrId)
|
||||
const dropIdx = tabs.indexOf(dropId)
|
||||
if (fromIdx < 0 || dropIdx < 0) return
|
||||
|
||||
const visibleDrop = libraryState.visibleTabIds[insertAt] ?? null
|
||||
const destIdx = visibleDrop ? tabs.indexOf(visibleDrop) : tabs.length
|
||||
tabs.splice(fromIdx, 1)
|
||||
const adjusted = Math.max(0, Math.min(destIdx > fromIdx ? destIdx - 1 : destIdx, tabs.length))
|
||||
tabs.splice(adjusted, 0, dragStrId)
|
||||
|
||||
libraryState.pinnedTabOrder = tabs
|
||||
updateSettings({ libraryPinnedTabOrder: tabs })
|
||||
const catIds = tabs.filter(id => id !== 'library' && id !== 'downloaded')
|
||||
const zeroCat = libraryState.categories.filter(c => c.id === 0)
|
||||
const reordered = catIds.map((id, i) => {
|
||||
const c = libraryState.categories.find(x => String(x.id) === id)!
|
||||
return { ...c, order: i + 1 }
|
||||
})
|
||||
libraryState.setCategories([...zeroCat, ...reordered])
|
||||
|
||||
if (dragStrId !== 'library' && dragStrId !== 'downloaded') {
|
||||
const serverPos = catIds.indexOf(dragStrId) + 1
|
||||
try {
|
||||
const cats = await getAdapter().updateCategoryOrder(Number(dragStrId), serverPos)
|
||||
libraryState.setCategories(cats)
|
||||
} catch { await loadCategories() }
|
||||
}
|
||||
}
|
||||
|
||||
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1 }
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="root"
|
||||
role="presentation"
|
||||
oncontextmenu={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return
|
||||
e.preventDefault()
|
||||
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H }
|
||||
}}
|
||||
>
|
||||
{#if libraryState.error}
|
||||
<div class="center">
|
||||
<p class="error-msg">Could not load library</p>
|
||||
<p class="error-detail">{libraryState.error}</p>
|
||||
<button class="retry-btn" onclick={() => { loadLibrary(); loadCategories() }}>Retry</button>
|
||||
</div>
|
||||
{:else}
|
||||
<LibraryToolbar
|
||||
tab={libraryState.tab}
|
||||
tabSortMode={libraryState.tabSort[libraryState.tab]?.mode ?? 'alphabetical'}
|
||||
tabSortDir={libraryState.tabSort[libraryState.tab]?.dir ?? 'asc'}
|
||||
tabStatus={libraryState.tabStatus[libraryState.tab] ?? 'ALL'}
|
||||
tabFilters={libraryState.tabFilters[libraryState.tab] ?? {}}
|
||||
hasActiveFilters={libraryState.hasActiveFilters}
|
||||
visibleCategories={libraryState.visibleCategories}
|
||||
visibleTabIds={libraryState.visibleTabIds}
|
||||
counts={libraryState.counts}
|
||||
query={libraryState.filter.query}
|
||||
refreshing={libraryState.refreshing}
|
||||
refreshProgress={libraryState.refreshProgress}
|
||||
refreshDone={libraryState.refreshDone}
|
||||
refreshingCatId={libraryState.refreshingCatId}
|
||||
{activeDragKind}
|
||||
{dragInsertIdx}
|
||||
{dragTabId}
|
||||
{dragOverTabId}
|
||||
onTabChange={(t) => libraryState.tab = t}
|
||||
onQuery={(q) => libraryState.filter.query = q}
|
||||
onSortChange={(mode) => libraryState.setTabSort(libraryState.tab, mode)}
|
||||
onSortDirToggle={() => libraryState.toggleTabSortDir(libraryState.tab)}
|
||||
onStatusChange={(s) => libraryState.setTabStatus(libraryState.tab, s)}
|
||||
onFilterToggle={(f) => libraryState.toggleTabFilter(libraryState.tab, f)}
|
||||
onFiltersClear={() => libraryState.clearTabFilters(libraryState.tab)}
|
||||
onRefresh={startRefresh}
|
||||
onCancelRefresh={cancelRefresh}
|
||||
onRefreshCategory={refreshCategory}
|
||||
onOpenDownloadsFolder={openDownloadsFolder}
|
||||
onTabDragStart={onTabDragStart}
|
||||
onTabDragOver={onTabDragOver}
|
||||
onTabDragLeave={onTabDragLeave}
|
||||
onTabDrop={onTabDrop}
|
||||
onTabDragEnd={onTabDragEnd}
|
||||
/>
|
||||
|
||||
{#if libraryState.refreshing && libraryState.refreshProgress.total > 0}
|
||||
{@const pct = Math.round((libraryState.refreshProgress.finished / libraryState.refreshProgress.total) * 100)}
|
||||
<div class="refresh-bar-wrap" aria-hidden="true">
|
||||
<div class="refresh-bar-fill" style="width:{pct}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<LibraryGrid
|
||||
items={libraryState.filteredItems}
|
||||
loading={libraryState.loading}
|
||||
selectMode={libraryState.selectMode}
|
||||
selected={libraryState.selected}
|
||||
tab={libraryState.tab}
|
||||
visibleCategories={libraryState.visibleCategories}
|
||||
{bulkWorking}
|
||||
onCardClick={onCardClick}
|
||||
onCardContextMenu={openCtx}
|
||||
onSelectAll={() => libraryState.selectAll(libraryState.filteredItems.map(m => m.id))}
|
||||
onExitSelect={() => libraryState.exitSelect()}
|
||||
onBulkRemove={onBulkRemove}
|
||||
onBulkMove={bulkMove}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ctx}
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
||||
{/if}
|
||||
{#if emptyCtx}
|
||||
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root {
|
||||
position: relative; display: flex; flex-direction: column;
|
||||
height: 100%; overflow: hidden;
|
||||
animation: fadeIn 0.14s ease both;
|
||||
}
|
||||
.center {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; height: 60%; gap: var(--sp-2);
|
||||
color: var(--text-muted); text-align: center;
|
||||
}
|
||||
.error-msg { color: var(--color-error); font-size: var(--text-base); }
|
||||
.error-detail { color: var(--text-faint); font-size: var(--text-sm); }
|
||||
.retry-btn {
|
||||
margin-top: var(--sp-3); padding: 6px 16px;
|
||||
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised); color: var(--text-muted);
|
||||
cursor: pointer; font-family: var(--font-ui);
|
||||
font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
}
|
||||
.refresh-bar-wrap { height: 2px; background: var(--border-dim); flex-shrink: 0; overflow: hidden; }
|
||||
.refresh-bar-fill { height: 100%; background: var(--accent); border-radius: 0 2px 2px 0; transition: width 0.6s ease; }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -113,9 +113,9 @@
|
||||
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each items as m (m.id)}
|
||||
{@const isSelected = selected.has(m.id)}
|
||||
{@const isCompleted = !m.unreadCount && (m.chapters?.totalCount ?? 0) > 0}
|
||||
{#each items as m (m.id)}
|
||||
{@const isSelected = selected.has(m.id)}
|
||||
{@const isCompleted = m.status === 'COMPLETED' || (!m.unreadCount && (m.chapters?.totalCount ?? 0) > 0)}
|
||||
<button
|
||||
class="card"
|
||||
class:card-selected={isSelected}
|
||||
|
||||
@@ -1,352 +1,264 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
MagnifyingGlass, Books, DownloadSimple, FolderSimple,
|
||||
SortAscending, CaretUp, CaretDown, ArrowsClockwise, X,
|
||||
} from 'phosphor-svelte'
|
||||
import LibraryFilters from './LibraryFilters.svelte'
|
||||
import type { LibrarySortOption, LibraryContentFilter, LibraryStatusFilter } from '$lib/state/library.svelte'
|
||||
import type { Category } from '$lib/types'
|
||||
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
|
||||
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
|
||||
} from "phosphor-svelte";
|
||||
import LibraryFilters from "./LibraryFilters.svelte";
|
||||
import type { Category } from "@types";
|
||||
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
|
||||
|
||||
interface Props {
|
||||
tab: string
|
||||
tabSortMode: LibrarySortOption
|
||||
tabSortDir: 'asc' | 'desc'
|
||||
tabStatus: LibraryStatusFilter
|
||||
tabFilters: Partial<Record<LibraryContentFilter, boolean>>
|
||||
hasActiveFilters: boolean
|
||||
visibleCategories: Category[]
|
||||
visibleTabIds: string[]
|
||||
counts: Record<string, number>
|
||||
query: string
|
||||
refreshing: boolean
|
||||
refreshProgress: { finished: number; total: number }
|
||||
refreshDone: boolean
|
||||
refreshingCatId: number | null
|
||||
activeDragKind: 'tab' | null
|
||||
dragInsertIdx: number
|
||||
dragTabId: string | null
|
||||
dragOverTabId: string | null
|
||||
onTabChange: (t: string) => void
|
||||
onQuery: (q: string) => void
|
||||
onSortChange: (mode: LibrarySortOption) => void
|
||||
onSortDirToggle: () => void
|
||||
onStatusChange: (s: LibraryStatusFilter) => void
|
||||
onFilterToggle: (f: LibraryContentFilter) => void
|
||||
onFiltersClear: () => void
|
||||
onRefresh: () => void
|
||||
onCancelRefresh: () => void
|
||||
onRefreshCategory: (catId: number) => void
|
||||
onOpenDownloadsFolder: () => void
|
||||
onTabDragStart: (e: DragEvent, id: string) => void
|
||||
onTabDragOver: (e: DragEvent, id: string, idx: number) => void
|
||||
onTabDragLeave: () => void
|
||||
onTabDrop: (e: DragEvent, id: string) => void
|
||||
onTabDragEnd: () => void
|
||||
tab: string;
|
||||
tabSortMode: LibrarySortMode;
|
||||
tabSortDir: LibrarySortDir;
|
||||
tabStatus: LibraryStatusFilter;
|
||||
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
|
||||
hasActiveFilters: boolean;
|
||||
anims: boolean;
|
||||
visibleCategories: Category[];
|
||||
visibleTabIds: string[];
|
||||
virtualTabIds: string[];
|
||||
folderTabIds: string[];
|
||||
completedCatId: number | null;
|
||||
counts: Record<string, number>;
|
||||
search: string;
|
||||
refreshing: boolean;
|
||||
refreshProgress: { finished: number; total: number };
|
||||
refreshDone: boolean;
|
||||
refreshingCatId: number | null;
|
||||
activeDragKind: "tab" | null;
|
||||
dragInsertIdx: number;
|
||||
dragTabId: string | null;
|
||||
dragOverTabId: string | null;
|
||||
sortPanelOpen: boolean;
|
||||
filterPanelOpen: boolean;
|
||||
tabsEl: HTMLDivElement;
|
||||
onSearchChange: (v: string) => void;
|
||||
onTabChange: (f: string) => void;
|
||||
onSortChange: (mode: LibrarySortMode) => void;
|
||||
onSortDirToggle: () => void;
|
||||
onStatusChange: (s: LibraryStatusFilter) => void;
|
||||
onFilterToggle: (f: LibraryContentFilter) => void;
|
||||
onFiltersClear: () => void;
|
||||
onSortPanelToggle: () => void;
|
||||
onFilterPanelToggle: () => void;
|
||||
onRefresh: () => void;
|
||||
onCancelRefresh: () => void;
|
||||
onRefreshCategory: (catId: number) => void;
|
||||
onOpenDownloadsFolder: () => void;
|
||||
onTabDragStart: (e: DragEvent, id: string) => void;
|
||||
onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
|
||||
onTabDragLeave: () => void;
|
||||
onTabDrop: (e: DragEvent, id: string) => void;
|
||||
onTabDragEnd: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
tab, tabSortMode, tabSortDir, tabStatus, tabFilters,
|
||||
hasActiveFilters, visibleCategories, visibleTabIds, counts, query,
|
||||
refreshing, refreshProgress, refreshDone, refreshingCatId,
|
||||
activeDragKind, dragInsertIdx, dragTabId, dragOverTabId,
|
||||
onTabChange, onQuery, onSortChange, onSortDirToggle,
|
||||
onStatusChange, onFilterToggle, onFiltersClear,
|
||||
onRefresh, onCancelRefresh, onOpenDownloadsFolder,
|
||||
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
|
||||
anims, visibleCategories, visibleTabIds, virtualTabIds, folderTabIds, completedCatId,
|
||||
counts, search, refreshing, refreshProgress, refreshDone, refreshingCatId,
|
||||
activeDragKind, dragInsertIdx, dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
|
||||
tabsEl = $bindable(),
|
||||
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
|
||||
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
|
||||
onRefresh, onCancelRefresh, onRefreshCategory, onOpenDownloadsFolder,
|
||||
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
|
||||
}: Props = $props()
|
||||
}: Props = $props();
|
||||
|
||||
let sortOpen = $state(false)
|
||||
let filterOpen = $state(false)
|
||||
|
||||
const SORT_LABELS: Record<LibrarySortOption, string> = {
|
||||
alphabetical: 'A–Z',
|
||||
unread: 'Unread chapters',
|
||||
lastRead: 'Recently read',
|
||||
dateAdded: 'Date added',
|
||||
totalChapters: 'Total chapters',
|
||||
latestFetched: 'Latest fetched',
|
||||
latestUploaded: 'Latest uploaded',
|
||||
}
|
||||
|
||||
function catById(id: string): Category | undefined {
|
||||
return visibleCategories.find(c => String(c.id) === id)
|
||||
}
|
||||
|
||||
function onDocDown(e: MouseEvent) {
|
||||
const t = e.target as HTMLElement
|
||||
if (sortOpen && !t.closest('.sort-wrap')) sortOpen = false
|
||||
if (filterOpen && !t.closest('.filter-wrap')) filterOpen = false
|
||||
function onTabsWheel(e: WheelEvent) {
|
||||
const ids = visibleTabIds.filter(id => id === "library" || id === "downloaded" || visibleCategories.some(c => String(c.id) === id));
|
||||
const idx = ids.indexOf(tab);
|
||||
if (e.deltaY > 0 && idx < ids.length - 1) onTabChange(ids[idx + 1]);
|
||||
else if (e.deltaY < 0 && idx > 0) onTabChange(ids[idx - 1]);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
document.addEventListener('mousedown', onDocDown, true)
|
||||
return () => document.removeEventListener('mousedown', onDocDown, true)
|
||||
})
|
||||
tab;
|
||||
if (!tabsEl) return;
|
||||
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
|
||||
if (!active) return;
|
||||
const pl = tabsEl.scrollLeft;
|
||||
const cw = tabsEl.clientWidth;
|
||||
const ol = active.offsetLeft;
|
||||
const ow = active.offsetWidth;
|
||||
if (ol < pl) tabsEl.scrollTo({ left: ol, behavior: "smooth" });
|
||||
else if (ol + ow > pl + cw) tabsEl.scrollTo({ left: ol + ow - cw, behavior: "smooth" });
|
||||
});
|
||||
|
||||
const SORT_LABELS: Record<LibrarySortMode, string> = {
|
||||
az: "A–Z",
|
||||
unreadCount: "Unread chapters",
|
||||
totalChapters: "Total chapters",
|
||||
recentlyAdded: "Recently added",
|
||||
recentlyRead: "Recently read",
|
||||
latestFetched: "Latest fetched chapter",
|
||||
latestUploaded: "Latest uploaded chapter",
|
||||
};
|
||||
|
||||
const ALL_SORT_MODES: LibrarySortMode[] = [
|
||||
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="header">
|
||||
<span class="heading">Library</span>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl} onwheel={onTabsWheel}>
|
||||
{#each visibleTabIds as id, idx}
|
||||
{@const isActive = tab === id}
|
||||
{@const isDragOver = dragOverTabId === id}
|
||||
{@const showInsertBefore = activeDragKind === 'tab' && dragInsertIdx === idx}
|
||||
{@const showInsertAfter = activeDragKind === 'tab' && dragInsertIdx === idx + 1 && idx === visibleTabIds.length - 1}
|
||||
|
||||
{#if showInsertBefore}
|
||||
<div class="drop-indicator" aria-hidden="true"></div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="tab"
|
||||
class:active={isActive}
|
||||
class:drag-over={isDragOver}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onTabDragStart(e, id)}
|
||||
ondragover={(e) => onTabDragOver(e, id, idx)}
|
||||
ondragleave={onTabDragLeave}
|
||||
ondrop={(e) => onTabDrop(e, id)}
|
||||
ondragend={onTabDragEnd}
|
||||
onclick={() => onTabChange(id)}
|
||||
>
|
||||
{#if id === 'library'}
|
||||
<Books size={11} weight="bold" />
|
||||
Library
|
||||
{:else if id === 'downloaded'}
|
||||
<DownloadSimple size={11} weight="bold" />
|
||||
Downloaded
|
||||
{:else}
|
||||
{@const cat = catById(id)}
|
||||
<FolderSimple size={11} weight="bold" />
|
||||
{cat?.name ?? id}
|
||||
{@const cat = visibleCategories.find(c => String(c.id) === id)}
|
||||
{#if id === "library" || id === "downloaded" || cat}
|
||||
{@const isBuiltin = id === "library" || id === "downloaded"}
|
||||
{@const isCompleted = cat && id === String(completedCatId)}
|
||||
{@const isDraggable = true}
|
||||
{#if activeDragKind === "tab" && dragInsertIdx === idx}
|
||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<button
|
||||
class="tab"
|
||||
class:active={tab === id}
|
||||
class:tab-dragging={isDraggable && dragTabId === id}
|
||||
draggable={isDraggable}
|
||||
onclick={() => onTabChange(id)}
|
||||
ondragstart={isDraggable ? (e) => onTabDragStart(e, id) : undefined}
|
||||
ondragover={isDraggable ? (e) => onTabDragOver(e, id, idx) : undefined}
|
||||
ondragleave={isDraggable ? onTabDragLeave : undefined}
|
||||
ondrop={isDraggable ? (e) => onTabDrop(e, id) : undefined}
|
||||
ondragend={isDraggable ? onTabDragEnd : undefined}
|
||||
>
|
||||
{#if id === "library"}<Books size={11} weight="bold" />
|
||||
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
|
||||
{:else if cat && id === String(completedCatId)}<CheckSquare size={11} weight="bold" />
|
||||
{:else if cat}<Folder size={11} weight="bold" />
|
||||
{/if}
|
||||
{id === "library" ? "Saved" : id === "downloaded" ? "Downloaded" : (cat?.name ?? id)}
|
||||
<span class="tab-count">{counts[id] ?? 0}</span>
|
||||
</button>
|
||||
{#if activeDragKind === "tab" && dragInsertIdx === idx + 1}
|
||||
<div class="tab-insert-bar" aria-hidden="true"></div>
|
||||
{/if}
|
||||
<span class="count">{counts[id] ?? 0}</span>
|
||||
</button>
|
||||
|
||||
{#if showInsertAfter}
|
||||
<div class="drop-indicator" aria-hidden="true"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={13} class="search-icon" weight="light" />
|
||||
<input
|
||||
class="search"
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
oninput={(e) => onQuery((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
<input class="search" placeholder="Search" value={search} oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
|
||||
{#if tab === 'downloaded'}
|
||||
<button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}>
|
||||
<FolderSimple size={15} weight="bold" />
|
||||
{#if refreshing}
|
||||
<button
|
||||
class="icon-btn refresh-btn icon-btn-active"
|
||||
title={`Checking… ${refreshProgress.finished}/${refreshProgress.total}`}
|
||||
onclick={onCancelRefresh}
|
||||
>
|
||||
<ArrowsClockwise size={15} weight="bold" class="anim-spin" />
|
||||
{#if refreshProgress.total > 0}
|
||||
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="icon-btn refresh-btn"
|
||||
class:refresh-btn-done={refreshDone}
|
||||
title={refreshDone ? "Library updated" : "Check for updates"}
|
||||
onclick={onRefresh}
|
||||
>
|
||||
<ArrowsClockwise size={15} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:spinning={refreshing}
|
||||
class:done={refreshDone}
|
||||
title={refreshing ? 'Cancel update' : 'Check for updates'}
|
||||
onclick={refreshing ? onCancelRefresh : onRefresh}
|
||||
>
|
||||
{#if refreshing}
|
||||
<X size={15} weight="bold" />
|
||||
{:else}
|
||||
<ArrowsClockwise size={15} weight="bold" />
|
||||
{/if}
|
||||
<button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}>
|
||||
<FolderSimple size={15} weight="bold" />
|
||||
</button>
|
||||
|
||||
{#if refreshing && refreshProgress.total > 0}
|
||||
<span class="refresh-label">
|
||||
{refreshProgress.finished}/{refreshProgress.total}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div class="sort-wrap">
|
||||
<div class="sort-panel-wrap">
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:active={tabSortMode !== 'alphabetical' || tabSortDir !== 'asc'}
|
||||
class:icon-btn-active={tabSortMode !== "az" || tabSortDir !== "asc"}
|
||||
title="Sort"
|
||||
onclick={() => { sortOpen = !sortOpen; filterOpen = false }}
|
||||
onclick={onSortPanelToggle}
|
||||
>
|
||||
<SortAscending size={15} weight="bold" />
|
||||
</button>
|
||||
|
||||
{#if sortOpen}
|
||||
<div class="panel sort-panel" role="menu">
|
||||
<div class="panel-head">
|
||||
<span class="panel-title">Sort</span>
|
||||
{#if sortPanelOpen}
|
||||
<div class="dropdown-panel sort-panel anim-fade-in" role="menu">
|
||||
<div class="panel-header">
|
||||
<span class="panel-heading">Sort</span>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<p class="section-label">Order by</p>
|
||||
{#each Object.entries(SORT_LABELS) as [s, label]}
|
||||
<div class="panel-divider"></div>
|
||||
<p class="panel-label">Order by</p>
|
||||
{#each ALL_SORT_MODES as m}
|
||||
<button
|
||||
class="item"
|
||||
class:item-active={tabSortMode === s}
|
||||
class="panel-item"
|
||||
class:panel-item-active={tabSortMode === m}
|
||||
role="menuitem"
|
||||
onclick={() => { onSortChange(s as LibrarySortOption); sortOpen = false }}
|
||||
onclick={() => onSortChange(m)}
|
||||
>
|
||||
{label}
|
||||
{#if tabSortMode === s}
|
||||
{#if tabSortDir === 'desc'}<CaretDown size={11} weight="bold" />
|
||||
{:else}<CaretUp size={11} weight="bold" />
|
||||
{/if}
|
||||
{SORT_LABELS[m]}
|
||||
{#if tabSortMode === m}
|
||||
{#if tabSortDir === "asc"}<CaretUp size={11} weight="bold" class="sort-caret" />
|
||||
{:else}<CaretDown size={11} weight="bold" class="sort-caret" />{/if}
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="item dir-toggle" role="menuitem" onclick={onSortDirToggle}>
|
||||
{tabSortDir === 'desc' ? 'Descending' : 'Ascending'}
|
||||
{#if tabSortDir === 'desc'}<CaretDown size={11} weight="bold" />
|
||||
{:else}<CaretUp size={11} weight="bold" />
|
||||
{/if}
|
||||
<button class="panel-item dir-toggle" role="menuitem" onclick={onSortDirToggle}>
|
||||
{tabSortDir === "asc" ? "Ascending" : "Descending"}
|
||||
{#if tabSortDir === "asc"}<CaretUp size={11} weight="bold" />
|
||||
{:else}<CaretDown size={11} weight="bold" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="filter-wrap">
|
||||
<LibraryFilters
|
||||
status={tabStatus}
|
||||
filters={tabFilters}
|
||||
hasActive={hasActiveFilters}
|
||||
open={filterOpen}
|
||||
onToggle={() => { filterOpen = !filterOpen; sortOpen = false }}
|
||||
{onStatusChange}
|
||||
{onFilterToggle}
|
||||
onClear={onFiltersClear}
|
||||
/>
|
||||
</div>
|
||||
<LibraryFilters
|
||||
{tabStatus}
|
||||
{tabFilters}
|
||||
{hasActiveFilters}
|
||||
{filterPanelOpen}
|
||||
{onStatusChange}
|
||||
{onFilterToggle}
|
||||
{onFiltersClear}
|
||||
{onFilterPanelToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toolbar {
|
||||
position: relative; z-index: 100;
|
||||
display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
flex-shrink: 0; min-width: 0; overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.toolbar::-webkit-scrollbar { display: none; }
|
||||
|
||||
.heading {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
||||
text-transform: uppercase; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md); padding: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||
padding: 4px 10px; border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent; color: var(--text-faint);
|
||||
white-space: nowrap; cursor: pointer;
|
||||
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
|
||||
user-select: none;
|
||||
}
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
.tab.drag-over { border-color: var(--accent); }
|
||||
|
||||
.count { font-size: var(--text-2xs); opacity: 0.6; }
|
||||
|
||||
.drop-indicator {
|
||||
width: 2px; height: 20px; background: var(--accent);
|
||||
border-radius: 1px; flex-shrink: 0;
|
||||
animation: fadeIn 0.1s ease both;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
margin-left: auto; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
|
||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; }
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); cursor: grab; flex-shrink: 0; }
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
.tab-dragging { opacity: 0.4; cursor: grabbing; }
|
||||
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
|
||||
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
|
||||
.search {
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md); padding: 5px 10px 5px 28px;
|
||||
color: var(--text-primary); font-size: var(--text-sm); width: 180px;
|
||||
outline: none; transition: border-color var(--t-base);
|
||||
}
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.refresh-label {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 30px; height: 30px;
|
||||
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised); color: var(--text-faint);
|
||||
cursor: pointer; flex-shrink: 0;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.icon-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.icon-btn.done { color: var(--color-success, #4caf50); border-color: color-mix(in srgb, var(--color-success, #4caf50) 40%, transparent); }
|
||||
.icon-btn.spinning :global(svg) { animation: spin 1s linear infinite; }
|
||||
|
||||
.sort-wrap, .filter-wrap { position: relative; }
|
||||
|
||||
.panel {
|
||||
position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999;
|
||||
min-width: 220px; 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: fadeIn 0.1s ease both;
|
||||
}
|
||||
.panel-head { display: flex; align-items: center; padding: 6px 10px 4px; }
|
||||
.panel-title {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide); color: var(--text-secondary);
|
||||
font-weight: var(--weight-medium, 500);
|
||||
}
|
||||
.divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
||||
.section-label {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
color: var(--text-faint); padding: 4px 8px 8px;
|
||||
}
|
||||
.item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
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; gap: var(--sp-2);
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
|
||||
.item-active:hover { background: var(--accent-dim); }
|
||||
.dir-toggle {
|
||||
justify-content: flex-start; color: var(--text-secondary);
|
||||
border-top: 1px solid var(--border-dim);
|
||||
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||
margin-top: 2px; padding-top: 9px;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
.refresh-btn { gap: var(--sp-1); width: auto; padding: 0 8px; }
|
||||
.refresh-btn:disabled { cursor: default; }
|
||||
.refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
|
||||
.refresh-btn-done { color: var(--color-success, #5cae6e) !important; border-color: color-mix(in srgb, var(--color-success, #5cae6e) 40%, transparent) !important; background: color-mix(in srgb, var(--color-success, #5cae6e) 10%, transparent) !important; }
|
||||
.sort-panel-wrap { position: relative; }
|
||||
.dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; 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); }
|
||||
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
|
||||
.panel-item { display: flex; align-items: center; justify-content: space-between; 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); gap: var(--sp-2); }
|
||||
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
|
||||
.panel-item-active:hover { background: var(--accent-dim); }
|
||||
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
|
||||
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
|
||||
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
|
||||
:global(.sort-caret) { flex-shrink: 0; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user