Chore: Revamped Shared Files for Svelte 5 Rewrite

This commit is contained in:
Youwes09
2026-03-19 23:43:43 -05:00
parent 94b92d000f
commit 96bac1ad2b
4 changed files with 239 additions and 208 deletions
+29 -29
View File
@@ -1,32 +1,32 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte";
export interface MenuItem { export interface MenuItem {
label: string; label: string;
icon?: any; icon?: any;
onClick: () => void; onClick: () => void;
danger?: boolean; danger?: boolean;
disabled?: boolean; disabled?: boolean;
separator?: never; separator?: never;
} }
export interface MenuSeparator { separator: true } export interface MenuSeparator { separator: true }
export type MenuEntry = MenuItem | MenuSeparator; export type MenuEntry = MenuItem | MenuSeparator;
export let x: number; interface Props {
export let y: number; x: number;
export let items: MenuEntry[]; y: number;
export let onClose: () => void; items: MenuEntry[];
onClose: () => void;
}
let focused = -1; let { x, y, items, onClose }: Props = $props();
let el: HTMLDivElement;
let focused = $state(-1);
let el = $state<HTMLDivElement | undefined>(undefined);
const actionable = items const actionable = items
.map((_, i) => i) .map((_, i) => i)
.filter((i) => !("separator" in items[i]) && !(items[i] as MenuItem).disabled); .filter((i) => !("separator" in items[i]) && !(items[i] as MenuItem).disabled);
$: if (actionable.length) focused = actionable[0]; const pos = $derived.by(() => {
function getPos() {
const zoom = parseFloat(document.documentElement.style.zoom || "100") / 100 || 1; const zoom = parseFloat(document.documentElement.style.zoom || "100") / 100 || 1;
const menuW = 200, menuH = items.length * 34; const menuW = 200, menuH = items.length * 34;
const vw = window.innerWidth / zoom, vh = window.innerHeight / zoom; const vw = window.innerWidth / zoom, vh = window.innerHeight / zoom;
@@ -35,7 +35,9 @@
left: Math.max(4, sx + menuW > vw ? sx - menuW : sx), left: Math.max(4, sx + menuW > vw ? sx - menuW : sx),
top: Math.max(4, sy + menuH > vh ? sy - menuH : sy), top: Math.max(4, sy + menuH > vh ? sy - menuH : sy),
}; };
} });
if (actionable.length) focused = actionable[0];
function onMouseDown(e: MouseEvent) { function onMouseDown(e: MouseEvent) {
if (el && !el.contains(e.target as Node)) onClose(); if (el && !el.contains(e.target as Node)) onClose();
@@ -62,20 +64,18 @@
} }
} }
onMount(() => { $effect(() => {
document.addEventListener("mousedown", onMouseDown, true); document.addEventListener("mousedown", onMouseDown, true);
document.addEventListener("keydown", onKey, true); document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("keydown", onKey, true);
};
}); });
onDestroy(() => {
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("keydown", onKey, true);
});
$: pos = getPos();
</script> </script>
<div bind:this={el} class="menu" role="menu" style="left:{pos.left}px;top:{pos.top}px" <div bind:this={el} class="menu" role="menu" style="left:{pos.left}px;top:{pos.top}px"
on:contextmenu|preventDefault> oncontextmenu={(e) => e.preventDefault()}>
{#each items as item, i} {#each items as item, i}
{#if "separator" in item} {#if "separator" in item}
<div class="sep"></div> <div class="sep"></div>
@@ -87,12 +87,12 @@
class:disabled={mi.disabled} class:disabled={mi.disabled}
class:focused={focused === i} class:focused={focused === i}
disabled={mi.disabled} disabled={mi.disabled}
on:click={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }} onclick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
on:mouseenter={() => { if (!mi.disabled) focused = i; }} onmouseenter={() => { if (!mi.disabled) focused = i; }}
on:mouseleave={() => focused = -1} onmouseleave={() => focused = -1}
> >
<span class="icon" class:icon-danger={mi.danger}> <span class="icon" class:icon-danger={mi.danger}>
{#if mi.icon}<svelte:component this={mi.icon} size={13} weight="light" />{/if} {#if mi.icon}<mi.icon size={13} weight="light" />{/if}
</span> </span>
<span class="label">{mi.label}</span> <span class="label">{mi.label}</span>
</button> </button>
+147 -130
View File
@@ -1,116 +1,119 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte";
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte"; import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries"; import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { settings, previewManga, activeManga, navPage, genreFilter, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga, getLinkedMangaIds } from "../../store";
import type { Manga, Chapter } from "../../lib/types";
import { GET_ALL_MANGA } from "../../lib/queries"; import { GET_ALL_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { settings, previewManga, activeManga, navPage, genreFilter, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga } from "../../store";
import type { Manga, Chapter } from "../../lib/types";
let manga: Manga | null = null; let manga: Manga | null = $state(null);
let chapters: Chapter[] = []; let chapters: Chapter[] = $state([]);
let loadingDetail = false; let loadingDetail = $state(false);
let loadingChapters = false; let loadingChapters = $state(false);
let togglingLib = false; let togglingLib = $state(false);
let descExpanded = false; let descExpanded = $state(false);
let folderOpen = false; let folderOpen = $state(false);
let newFolderName = ""; let newFolderName = $state("");
let creatingFolder = false; let creatingFolder = $state(false);
let queueingAll = false; let queueingAll = $state(false);
let fetchError: string|null = null; let fetchError: string | null = $state(null);
let folderRef: HTMLDivElement; let folderRef = $state<HTMLDivElement | undefined>(undefined);
// ── Link picker ────────────────────────────────────────────────────────────── let linkPickerOpen = $state(false);
let linkPickerOpen = false; let linkSearch = $state("");
let linkSearch = ""; let allMangaForLink: Manga[] = $state([]);
let allMangaForLink: Manga[] = []; let loadingLinkList = $state(false);
let loadingLinkList = false;
$: linkedIds = $previewManga ? ($settings.mangaLinks?.[$previewManga.id] ?? []) : []; const linkedIds = $derived(previewManga ? (settings.mangaLinks?.[previewManga.id] ?? []) : []);
$: linkPickerResults = (() => { const linkPickerResults = $derived.by(() => {
const others = allMangaForLink.filter(m => m.id !== $previewManga?.id); const others = allMangaForLink.filter((m) => m.id !== previewManga?.id);
const q = linkSearch.trim().toLowerCase(); const q = linkSearch.trim().toLowerCase();
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others; const filtered = q ? others.filter((m) => m.title.toLowerCase().includes(q)) : others;
// Linked entries bubble to the top const linked = filtered.filter((m) => linkedIds.includes(m.id));
const linked = filtered.filter(m => linkedIds.includes(m.id)); const rest = filtered.filter((m) => !linkedIds.includes(m.id)).slice(0, 30);
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
return [...linked, ...rest]; return [...linked, ...rest];
})(); });
async function openLinkPicker() { const displayManga = $derived(manga ?? previewManga);
linkPickerOpen = true; const totalCount = $derived(chapters.length);
linkSearch = ""; const readCount = $derived(chapters.filter((c) => c.isRead).length);
if (allMangaForLink.length) return; const unreadCount = $derived(totalCount - readCount);
loadingLinkList = true; const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA) const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
.then(d => { allMangaForLink = d.mangas.nodes; }) const inLibrary = $derived(manga?.inLibrary ?? previewManga?.inLibrary ?? false);
.catch(console.error) const scanlators = $derived([...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))]);
.finally(() => { loadingLinkList = false; }); const uploadDates = $derived(chapters.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null).filter((d): d is number => d !== null && !isNaN(d)));
} const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
const assignedFolders = $derived(previewManga ? settings.folders.filter((f) => f.mangaIds.includes(previewManga!.id)) : []);
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; } const continueChapter = $derived.by(() => {
function handleLink(other: Manga) {
if (!$previewManga) return;
if (linkedIds.includes(other.id)) {
unlinkManga($previewManga.id, other.id);
} else {
linkManga($previewManga.id, other.id);
}
}
let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
function close() {
detailAbort?.abort(); chapterAbort?.abort();
previewManga.set(null);
manga = null; chapters = []; descExpanded = false;
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
}
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
$: displayManga = manga ?? $previewManga;
$: totalCount = chapters.length;
$: readCount = chapters.filter((c) => c.isRead).length;
$: unreadCount = totalCount - readCount;
$: downloadedCount = chapters.filter((c) => c.isDownloaded).length;
$: bookmarkCount = chapters.filter((c) => c.isBookmarked).length;
$: inLibrary = manga?.inLibrary ?? $previewManga?.inLibrary ?? false;
$: scanlators = [...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))];
$: uploadDates = chapters.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null).filter((d): d is number => d !== null && !isNaN(d));
$: firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null;
$: lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null;
$: statusLabel = displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null;
$: assignedFolders = $previewManga ? $settings.folders.filter((f) => f.mangaIds.includes($previewManga!.id)) : [];
$: continueChapter = (() => {
if (!chapters.length) return null; if (!chapters.length) return null;
const inProgress = chapters.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0); const inProgress = chapters.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` }; if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
const firstUnread = chapters.find((c) => !c.isRead); const firstUnread = chapters.find((c) => !c.isRead);
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` }; if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
return { ch: chapters[0], label: "Read again" }; return { ch: chapters[0], label: "Read again" };
})(); });
$: if ($previewManga) load($previewManga.id); let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
$effect(() => {
if (previewManga) load(previewManga.id);
});
$effect(() => {
return () => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); };
});
$effect(() => {
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
$effect(() => {
if (!folderOpen) {
document.removeEventListener("mousedown", handleFolderOutside);
return;
}
const timer = setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
return () => {
clearTimeout(timer);
document.removeEventListener("mousedown", handleFolderOutside);
};
});
function close() {
detailAbort?.abort(); chapterAbort?.abort();
previewManga = null;
manga = null;
chapters = [];
descExpanded = false;
folderOpen = false;
creatingFolder = false;
newFolderName = "";
fetchError = null;
}
function formatDate(d: Date) {
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
async function load(id: number) { async function load(id: number) {
detailAbort?.abort(); chapterAbort?.abort(); detailAbort?.abort(); chapterAbort?.abort();
const dCtrl = new AbortController(), cCtrl = new AbortController(); const dCtrl = new AbortController(), cCtrl = new AbortController();
detailAbort = dCtrl; chapterAbort = cCtrl; detailAbort = dCtrl; chapterAbort = cCtrl;
// Pre-populate from the shallow grid entry immediately — description/genres manga = previewManga as Manga;
// will appear as soon as the full fetch resolves, but title/cover show now.
manga = $previewManga as Manga;
chapters = []; descExpanded = false; fetchError = null; chapters = []; descExpanded = false; fetchError = null;
loadingDetail = true; loadingChapters = true; loadingDetail = true; loadingChapters = true;
(async (): Promise<Manga> => { (async (): Promise<Manga> => {
const key = CACHE_KEYS.MANGA(id); const key = CACHE_KEYS.MANGA(id);
if (cache.has(key)) return cache.get(key, () => Promise.resolve($previewManga as Manga)) as Promise<Manga>; if (cache.has(key)) return cache.get(key, () => Promise.resolve(previewManga as Manga)) as Promise<Manga>;
try { try {
const d = await gql<{ fetchManga: { manga: Manga } }>(FETCH_MANGA, { id }, dCtrl.signal); const d = await gql<{ fetchManga: { manga: Manga } }>(FETCH_MANGA, { id }, dCtrl.signal);
return d.fetchManga.manga; return d.fetchManga.manga;
@@ -126,8 +129,8 @@
manga = fullManga; loadingDetail = false; manga = fullManga; loadingDetail = false;
}).catch((e) => { }).catch((e) => {
if (e?.name === "AbortError") return; if (e?.name === "AbortError") return;
manga = $previewManga as Manga; manga = previewManga as Manga;
fetchError = "Could not load full details — showing cached data"; fetchError = "Could not load full details — showing cached data";
loadingDetail = false; loadingDetail = false;
}); });
@@ -143,7 +146,6 @@
} }
if (!cCtrl.signal.aborted) { if (!cCtrl.signal.aborted) {
chapters = nodes; chapters = nodes;
// Passive check — MangaPreview has the full chapter list, use it
if (nodes.length > 0) checkAndMarkCompleted(id, nodes); if (nodes.length > 0) checkAndMarkCompleted(id, nodes);
} }
}) })
@@ -154,7 +156,7 @@
async function toggleLibrary() { async function toggleLibrary() {
if (!manga) return; if (!manga) return;
togglingLib = true; togglingLib = true;
const next = !manga.inLibrary; const next = !manga.inLibrary;
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error); await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
manga = { ...manga, inLibrary: next }; manga = { ...manga, inLibrary: next };
cache.clear(CACHE_KEYS.MANGA(manga.id)); cache.clear(CACHE_KEYS.MANGA(manga.id));
@@ -175,39 +177,60 @@
function openSeriesDetail() { function openSeriesDetail() {
if (!displayManga) return; if (!displayManga) return;
activeManga.set(displayManga); activeManga = displayManga;
navPage.set("library"); navPage = "library";
close(); close();
} }
function handleFolderCreate() { function handleFolderCreate() {
const name = newFolderName.trim(); const name = newFolderName.trim();
if (!name || !$previewManga) return; if (!name || !previewManga) return;
const id = addFolder(name); const id = addFolder(name);
assignMangaToFolder(id, $previewManga.id); assignMangaToFolder(id, previewManga.id);
newFolderName = ""; creatingFolder = false; newFolderName = ""; creatingFolder = false;
} }
function handleFolderOutside(e: MouseEvent) { function handleFolderOutside(e: MouseEvent) {
if (folderRef && !folderRef.contains(e.target as Node)) { folderOpen = false; creatingFolder = false; newFolderName = ""; } if (folderRef && !folderRef.contains(e.target as Node)) {
folderOpen = false; creatingFolder = false; newFolderName = "";
}
} }
$: if (folderOpen) setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
else document.removeEventListener("mousedown", handleFolderOutside);
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); } function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
onMount(() => window.addEventListener("keydown", onKey));
onDestroy(() => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); }); async function openLinkPicker() {
linkPickerOpen = true;
linkSearch = "";
if (allMangaForLink.length) return;
loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
.then((d) => { allMangaForLink = d.mangas.nodes; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
function handleLink(other: Manga) {
if (!previewManga) return;
if (linkedIds.includes(other.id)) {
unlinkManga(previewManga.id, other.id);
} else {
linkManga(previewManga.id, other.id);
}
}
</script> </script>
{#if $previewManga} {#if previewManga}
<div class="backdrop" role="presentation" on:click={(e) => { if (e.target === e.currentTarget) close(); }} on:keydown={(e) => { if (e.key === "Escape") close(); }}> <div class="backdrop" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) close(); }}
onkeydown={(e) => { if (e.key === "Escape") close(); }}>
<div class="modal" role="dialog" aria-label="Manga preview"> <div class="modal" role="dialog" aria-label="Manga preview">
<!-- Cover column --> <!-- Cover column -->
<div class="cover-col"> <div class="cover-col">
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl($previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" /> <img src={thumbUrl(previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
{#if loadingDetail} {#if loadingDetail}
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div> <div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
{/if} {/if}
@@ -215,30 +238,30 @@
<div class="cover-actions"> <div class="cover-actions">
<!-- Library --> <!-- Library -->
<button class="action-btn" class:active={inLibrary} on:click={toggleLibrary} disabled={togglingLib || loadingDetail}> <button class="action-btn" class:active={inLibrary} onclick={toggleLibrary} disabled={togglingLib || loadingDetail}>
<span class="action-icon"><BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /></span> <span class="action-icon"><BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /></span>
<span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span> <span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span>
</button> </button>
<!-- Series Detail --> <!-- Series Detail -->
<button class="action-btn" on:click={openSeriesDetail}> <button class="action-btn" onclick={openSeriesDetail}>
<span class="action-icon"><Books size={13} weight="light" /></span> <span class="action-icon"><Books size={13} weight="light" /></span>
<span class="action-label">Series Detail</span> <span class="action-label">Series Detail</span>
</button> </button>
<!-- Folders --> <!-- Folders -->
<div class="folder-wrap" bind:this={folderRef}> <div class="folder-wrap" bind:this={folderRef}>
<button class="action-btn" class:active={assignedFolders.length > 0} on:click={() => folderOpen = !folderOpen}> <button class="action-btn" class:active={assignedFolders.length > 0} onclick={() => folderOpen = !folderOpen}>
<span class="action-icon"><FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} /></span> <span class="action-icon"><FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} /></span>
<span class="action-label">{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}</span> <span class="action-label">{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}</span>
</button> </button>
{#if folderOpen} {#if folderOpen}
<div class="folder-menu"> <div class="folder-menu">
{#if $settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if} {#if settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
{#each $settings.folders as f} {#each settings.folders as f}
{@const isIn = $previewManga ? f.mangaIds.includes($previewManga.id) : false} {@const isIn = previewManga ? f.mangaIds.includes(previewManga.id) : false}
<button class="folder-item" class:folder-item-on={isIn} <button class="folder-item" class:folder-item-on={isIn}
on:click={() => $previewManga && (isIn ? removeMangaFromFolder(f.id, $previewManga.id) : assignMangaToFolder(f.id, $previewManga.id))}> onclick={() => previewManga && (isIn ? removeMangaFromFolder(f.id, previewManga.id) : assignMangaToFolder(f.id, previewManga.id))}>
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name} <Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name}
</button> </button>
{/each} {/each}
@@ -246,19 +269,19 @@
{#if creatingFolder} {#if creatingFolder}
<div class="folder-create-row"> <div class="folder-create-row">
<input class="folder-input" placeholder="Folder name…" bind:value={newFolderName} <input class="folder-input" placeholder="Folder name…" bind:value={newFolderName}
on:keydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }} autofocus
use:focus /> onkeydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }} />
<button class="folder-ok" on:click={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button> <button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
</div> </div>
{:else} {:else}
<button class="folder-new" on:click={() => creatingFolder = true}>+ New folder</button> <button class="folder-new" onclick={() => creatingFolder = true}>+ New folder</button>
{/if} {/if}
</div> </div>
{/if} {/if}
</div> </div>
<!-- Series Link --> <!-- Series Link -->
<button class="action-btn" class:active={linkedIds.length > 0} on:click={openLinkPicker}> <button class="action-btn" class:active={linkedIds.length > 0} onclick={openLinkPicker}>
<span class="action-icon"><LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} /></span> <span class="action-icon"><LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} /></span>
<span class="action-label">{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}</span> <span class="action-label">{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}</span>
</button> </button>
@@ -277,7 +300,7 @@
<p class="byline">{[displayManga?.author, displayManga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p> <p class="byline">{[displayManga?.author, displayManga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
{/if} {/if}
</div> </div>
<button class="close-btn" on:click={close}><X size={15} weight="light" /></button> <button class="close-btn" onclick={close}><X size={15} weight="light" /></button>
</div> </div>
<div class="content-body"> <div class="content-body">
@@ -309,7 +332,7 @@
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}{unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""}{downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""} {totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}{unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""}{downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""}
</span> </span>
{#if unreadCount > 0} {#if unreadCount > 0}
<button class="dl-all-btn" on:click={downloadAll} disabled={queueingAll}> <button class="dl-all-btn" onclick={downloadAll} disabled={queueingAll}>
{#if queueingAll}<CircleNotch size={11} weight="light" class="anim-spin" />{/if} {#if queueingAll}<CircleNotch size={11} weight="light" class="anim-spin" />{/if}
{queueingAll ? "Queuing…" : "Download unread"} {queueingAll ? "Queuing…" : "Download unread"}
</button> </button>
@@ -319,7 +342,7 @@
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div> <div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
{/if} {/if}
{#if continueChapter} {#if continueChapter}
<button class="read-btn" on:click={() => { openReader(continueChapter!.ch, chapters); close(); }}> <button class="read-btn" onclick={() => { openReader(continueChapter!.ch, chapters); close(); }}>
<Play size={12} weight="fill" />{continueChapter.label} <Play size={12} weight="fill" />{continueChapter.label}
</button> </button>
{/if} {/if}
@@ -339,7 +362,7 @@
<div class="desc-block"> <div class="desc-block">
<p class="desc" class:desc-open={descExpanded}>{displayManga.description}</p> <p class="desc" class:desc-open={descExpanded}>{displayManga.description}</p>
{#if displayManga.description.length > 220} {#if displayManga.description.length > 220}
<button class="desc-toggle" on:click={() => descExpanded = !descExpanded}> <button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>
{descExpanded ? "Show less" : "Show more"} {descExpanded ? "Show less" : "Show more"}
<CaretDown size={10} weight="light" style="transform:{descExpanded ? 'rotate(180deg)' : 'none'};transition:transform 0.15s ease" /> <CaretDown size={10} weight="light" style="transform:{descExpanded ? 'rotate(180deg)' : 'none'};transition:transform 0.15s ease" />
</button> </button>
@@ -351,7 +374,7 @@
{#if !loadingDetail && displayManga?.genre?.length} {#if !loadingDetail && displayManga?.genre?.length}
<div class="genres"> <div class="genres">
{#each displayManga.genre as g} {#each displayManga.genre as g}
<button class="genre-tag" on:click={() => { genreFilter.set(g); navPage.set("explore"); close(); }}>{g}</button> <button class="genre-tag" onclick={() => { genreFilter = g; navPage = "explore"; close(); }}>{g}</button>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -379,22 +402,22 @@
</div> </div>
</div> </div>
<!-- ── Link picker modal ───────────────────────────────────────────────────── --> <!-- Link picker modal -->
{#if linkPickerOpen} {#if linkPickerOpen}
<div class="link-backdrop" role="presentation" <div class="link-backdrop" role="presentation"
on:click|self={closeLinkPicker} onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
on:keydown={(e) => e.key === "Escape" && closeLinkPicker()}> onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
<div class="link-modal"> <div class="link-modal">
<div class="link-header"> <div class="link-header">
<span class="link-title">Link as same series</span> <span class="link-title">Link as same series</span>
<button class="close-btn" on:click={closeLinkPicker}><X size={14} weight="light" /></button> <button class="close-btn" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
</div> </div>
<p class="link-hint"> <p class="link-hint">
Mark two manga as the same series so duplicates are merged in search and discover. Mark two manga as the same series so duplicates are merged in search and discover.
Click a linked entry again to unlink. Click a linked entry again to unlink.
</p> </p>
<div class="link-search-wrap"> <div class="link-search-wrap">
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focus /> <input class="link-search" placeholder="Search your library…" bind:value={linkSearch} autofocus />
</div> </div>
<div class="link-list"> <div class="link-list">
{#if loadingLinkList} {#if loadingLinkList}
@@ -404,7 +427,7 @@
{:else} {:else}
{#each linkPickerResults as m (m.id)} {#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)} {@const isLinked = linkedIds.includes(m.id)}
<button class="link-row" class:link-row-linked={isLinked} on:click={() => handleLink(m)}> <button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" /> <img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
<div class="link-info"> <div class="link-info">
<span class="link-manga-title">{m.title}</span> <span class="link-manga-title">{m.title}</span>
@@ -421,10 +444,6 @@
{/if} {/if}
<script context="module">
function focus(node: HTMLElement) { node.focus(); }
</script>
<style> <style>
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } .backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); } .modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
@@ -444,7 +463,6 @@
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); } .action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
.action-btn:disabled { opacity: 0.4; cursor: default; } .action-btn:disabled { opacity: 0.4; cursor: default; }
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); } .action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
/* Fixed-width icon slot — keeps all labels optically aligned */
.action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; } .action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; }
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.folder-wrap { position: relative; width: 100%; } .folder-wrap { position: relative; width: 100%; }
@@ -508,7 +526,6 @@
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); } .meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); }
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); } .meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
.meta-link:hover { opacity: 0.75; } .meta-link:hover { opacity: 0.75; }
/* ── Link picker ─────────────────────────────────────────────────────────── */
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; } .link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; } .link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; } .link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
+43 -30
View File
@@ -8,31 +8,31 @@
type BrowseType = "POPULAR" | "LATEST" | "SEARCH"; type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
let mangas: Manga[] = []; let mangas: Manga[] = $state([]);
let loading = true; let loading = $state(true);
let page = 1; let page = $state(1);
let hasNextPage = false; let hasNextPage = $state(false);
let browseType: BrowseType = "POPULAR"; let browseType: BrowseType = $state("POPULAR");
let search = ""; let search = $state("");
let searchInput = ""; let searchInput = $state("");
let ctx: { x: number; y: number; manga: Manga } | null = null; let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
async function fetchMangas(type: BrowseType, p: number, q: string) { async function fetchMangas(type: BrowseType, p: number, q: string) {
if (!$activeSource) return; if (!activeSource) return;
loading = true; mangas = []; loading = true; mangas = [];
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA, { source: $activeSource.id, type, page: p, query: q || null } FETCH_SOURCE_MANGA, { source: activeSource.id, type, page: p, query: q || null }
).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; }) ).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; })
.catch(console.error) .catch(console.error)
.finally(() => loading = false); .finally(() => { loading = false; });
} }
$: if ($activeSource) fetchMangas(browseType, page, search); $effect(() => { if (activeSource) fetchMangas(browseType, page, search); });
function submitSearch() { function submitSearch() {
search = searchInput.trim(); search = searchInput.trim();
browseType = "SEARCH"; browseType = "SEARCH";
page = 1; page = 1;
} }
function setMode(mode: BrowseType) { function setMode(mode: BrowseType) {
@@ -42,36 +42,48 @@
function buildCtxItems(m: Manga): MenuEntry[] { function buildCtxItems(m: Manga): MenuEntry[] {
return [ return [
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary, {
label: m.inLibrary ? "In Library" : "Add to library",
icon: BookmarkSimple,
disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }) onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)) .then(() => { mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); })
.catch(console.error) }, .catch(console.error),
...($settings.folders.length > 0 ? [ },
...(settings.folders.length > 0 ? [
{ separator: true } as MenuEntry, { separator: true } as MenuEntry,
...$settings.folders.map((f): MenuEntry => ({ ...settings.folders.map((f): MenuEntry => ({
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder, label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
icon: Folder,
onClick: () => assignMangaToFolder(f.id, m.id), onClick: () => assignMangaToFolder(f.id, m.id),
})), })),
] : []), ] : []),
{ separator: true }, { separator: true },
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } }, {
label: "New folder & add",
icon: FolderSimplePlus,
onClick: () => {
const name = prompt("Folder name:");
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
},
},
]; ];
} }
</script> </script>
{#if $activeSource} {#if activeSource}
<div class="root"> <div class="root">
<div class="header"> <div class="header">
<button class="back" on:click={() => activeSource.set(null)}> <button class="back" onclick={() => activeSource = null}>
<ArrowLeft size={13} weight="light" /><span>Sources</span> <ArrowLeft size={13} weight="light" /><span>Sources</span>
</button> </button>
<span class="source-name">{$activeSource.displayName}</span> <span class="source-name">{activeSource.displayName}</span>
</div> </div>
<div class="toolbar"> <div class="toolbar">
<div class="tabs"> <div class="tabs">
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode} {#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
<button class="tab" class:active={browseType === mode && !search} on:click={() => setMode(mode)}> <button class="tab" class:active={browseType === mode && !search} onclick={() => setMode(mode)}>
{mode.charAt(0) + mode.slice(1).toLowerCase()} {mode.charAt(0) + mode.slice(1).toLowerCase()}
</button> </button>
{/each} {/each}
@@ -80,7 +92,7 @@
<div class="search-wrap"> <div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" /> <MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search source…" bind:value={searchInput} <input class="search" placeholder="Search source…" bind:value={searchInput}
on:keydown={(e) => e.key === "Enter" && submitSearch()} /> onkeydown={(e) => e.key === "Enter" && submitSearch()} />
</div> </div>
</div> </div>
@@ -95,8 +107,9 @@
{:else} {:else}
<div class="grid"> <div class="grid">
{#each mangas as m (m.id)} {#each mangas as m (m.id)}
<button class="card" on:click={() => { activeManga.set(m); navPage.set("library"); }} <button class="card"
on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}> onclick={() => { activeManga = m; navPage = "library"; }}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" /> <img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if} {#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
@@ -109,11 +122,11 @@
{#if !loading && (page > 1 || hasNextPage)} {#if !loading && (page > 1 || hasNextPage)}
<div class="pagination"> <div class="pagination">
<button class="page-btn" on:click={() => page = Math.max(1, page - 1)} disabled={page === 1}> <button class="page-btn" onclick={() => page = Math.max(1, page - 1)} disabled={page === 1}>
<Prev size={13} weight="light" /> Prev <Prev size={13} weight="light" /> Prev
</button> </button>
<span class="page-num">{page}</span> <span class="page-num">{page}</span>
<button class="page-btn" on:click={() => page++} disabled={!hasNextPage}> <button class="page-btn" onclick={() => page++} disabled={!hasNextPage}>
Next <Next size={13} weight="light" /> Next <Next size={13} weight="light" />
</button> </button>
</div> </div>
+20 -19
View File
@@ -1,41 +1,41 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte";
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte"; import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES } from "../../lib/queries"; import { GET_SOURCES } from "../../lib/queries";
import { activeSource } from "../../store"; import { activeSource } from "../../store";
import type { Source } from "../../lib/types"; import type { Source } from "../../lib/types";
let sources: Source[] = []; let sources: Source[] = $state([]);
let loading = true; let loading = $state(true);
let lang = "all"; let lang = $state("all");
let search = ""; let search = $state("");
let expanded = new Set<string>(); let expanded = $state(new Set<string>());
onMount(() => { $effect(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => sources = d.sources.nodes) .then((d) => { sources = d.sources.nodes; })
.catch(console.error) .catch(console.error)
.finally(() => loading = false); .finally(() => { loading = false; });
}); });
$: langs = ["all", ...Array.from(new Set(sources.map((s) => s.lang))).sort()]; const langs = $derived(["all", ...Array.from(new Set(sources.map((s) => s.lang))).sort()]);
$: filtered = sources.filter((src) => { const filtered = $derived(sources.filter((src) => {
if (src.id === "0") return false; if (src.id === "0") return false;
const matchLang = lang === "all" || src.lang === lang; const matchLang = lang === "all" || src.lang === lang;
const matchSearch = src.name.toLowerCase().includes(search.toLowerCase()) || src.displayName.toLowerCase().includes(search.toLowerCase()); const matchSearch = src.name.toLowerCase().includes(search.toLowerCase())
|| src.displayName.toLowerCase().includes(search.toLowerCase());
return matchLang && matchSearch; return matchLang && matchSearch;
}); }));
$: groups = (() => { const groups = $derived.by(() => {
const map = new Map<string, { name: string; icon: string; sources: Source[] }>(); const map = new Map<string, { name: string; icon: string; sources: Source[] }>();
for (const src of filtered) { for (const src of filtered) {
if (!map.has(src.name)) map.set(src.name, { name: src.name, icon: src.iconUrl, sources: [] }); if (!map.has(src.name)) map.set(src.name, { name: src.name, icon: src.iconUrl, sources: [] });
map.get(src.name)!.sources.push(src); map.get(src.name)!.sources.push(src);
} }
return Array.from(map.values()); return Array.from(map.values());
})(); });
function toggleGroup(name: string) { function toggleGroup(name: string) {
const next = new Set(expanded); const next = new Set(expanded);
@@ -55,7 +55,7 @@
<div class="lang-row"> <div class="lang-row">
{#each langs as l} {#each langs as l}
<button class="lang-btn" class:active={lang === l} on:click={() => lang = l}> <button class="lang-btn" class:active={lang === l} onclick={() => lang = l}>
{l === "all" ? "All" : l.toUpperCase()} {l === "all" ? "All" : l.toUpperCase()}
</button> </button>
{/each} {/each}
@@ -71,8 +71,9 @@
{@const single = g.sources.length === 1} {@const single = g.sources.length === 1}
{@const open = expanded.has(g.name)} {@const open = expanded.has(g.name)}
<div> <div>
<button class="row" on:click={() => single ? activeSource.set(g.sources[0]) : toggleGroup(g.name)}> <button class="row" onclick={() => single ? activeSource = g.sources[0] : toggleGroup(g.name)}>
<img src={thumbUrl(g.icon)} alt={g.name} class="icon" on:error={(e) => (e.target as HTMLImageElement).style.display = "none"} /> <img src={thumbUrl(g.icon)} alt={g.name} class="icon"
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
<div class="info"> <div class="info">
<span class="name">{g.name}</span> <span class="name">{g.name}</span>
<span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span> <span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span>
@@ -83,7 +84,7 @@
</button> </button>
{#if !single && open} {#if !single && open}
{#each g.sources as src} {#each g.sources as src}
<button class="row row-indented" on:click={() => activeSource.set(src)}> <button class="row row-indented" onclick={() => activeSource = src}>
<div class="indent-spacer"></div> <div class="indent-spacer"></div>
<div class="info"><span class="name">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span></div> <div class="info"><span class="name">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span></div>
<span class="arrow"></span> <span class="arrow"></span>