Feat: Markers

This commit is contained in:
Youwes09
2026-04-02 18:07:49 -05:00
parent 454a674ada
commit 51bb6cdab9
6 changed files with 476 additions and 16 deletions
+20 -2
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye } from "phosphor-svelte";
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye, MapPin } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, setPreviewManga, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, setPreviewManga, checkAndMarkCompleted as storeCheckAndMarkCompleted, clearMarkersForManga } from "../../store/state.svelte";
import type { MangaPrefs } from "../../store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types";
@@ -13,6 +13,7 @@
import MigrateModal from "./MigrateModal.svelte";
import TrackingPanel from "../shared/TrackingPanel.svelte";
import AutomationPanel from "../shared/AutomationPanel.svelte";
import MarkersPanel from "../shared/MarkersPanel.svelte";
const CHAPTERS_PER_PAGE = 25;
const MANGA_TTL_MS = 5 * 60 * 1000;
@@ -49,6 +50,7 @@
let migrateOpen: boolean = $state(false);
let autoOpen: boolean = $state(false);
let trackingOpen: boolean = $state(false);
let markersOpen: boolean = $state(false);
let linkPickerOpen: boolean = $state(false);
let linkSearch: string = $state("");
let allMangaForLink: Manga[] = $state([]);
@@ -592,6 +594,10 @@
<button class="detail-action-btn" onclick={() => trackingOpen = true}>
<ChartLineUp size={12} weight="light" /> Tracking
</button>
<button class="detail-action-btn" class:detail-action-active={markersOpen} onclick={() => markersOpen = !markersOpen}>
<MapPin size={12} weight={markersOpen ? "fill" : "light"} />
Markers{store.activeManga && store.getMarkersForManga(store.activeManga.id).length > 0 ? ` (${store.getMarkersForManga(store.activeManga.id).length})` : ""}
</button>
{#if manga?.inLibrary}
<button class="detail-action-btn" class:detail-action-active={hasAnyAutomation} onclick={() => autoOpen = true}>
<Gear size={12} weight={hasAnyAutomation ? "fill" : "light"} /> Automation
@@ -856,6 +862,14 @@
<AutomationPanel mangaId={store.activeManga.id} {chapters} onClose={() => autoOpen = false} />
{/if}
{#if markersOpen && store.activeManga}
<div class="markers-panel-overlay" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) markersOpen = false; }}>
<div class="markers-panel-drawer">
<MarkersPanel mangaId={store.activeManga.id} {chapters} onClose={() => markersOpen = false} />
</div>
</div>
{/if}
{#if linkPickerOpen}
<div class="link-backdrop" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
@@ -1104,4 +1118,8 @@
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
.markers-panel-overlay { position: fixed; inset: 0; z-index: var(--z-settings); display: flex; align-items: stretch; justify-content: flex-start; animation: fadeIn 0.12s ease both; }
.markers-panel-drawer { width: 280px; max-width: 90vw; background: var(--bg-surface); border-right: 1px solid var(--border-base); box-shadow: 4px 0 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both; }
@keyframes drawerIn { from { opacity: 0; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } }
</style>
+193 -13
View File
@@ -4,14 +4,14 @@
X, CaretLeft, CaretRight, ArrowLeft, ArrowRight,
Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
CircleNotch, MagnifyingGlassMinus, MagnifyingGlassPlus,
Bookmark, BookOpen, MonitorPlay,
Bookmark, BookOpen, MonitorPlay, MapPin, Check,
} from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark } from "../../store/state.svelte";
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, addMarker, removeMarker, updateMarker } from "../../store/state.svelte";
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
import { setReading } from "../../lib/discord";
import type { FitMode } from "../../store/state.svelte";
import type { FitMode, MarkerColor } from "../../store/state.svelte";
const AVG_MIN_PER_PAGE = 0.33;
const READ_LINE_PCT = 0.20;
@@ -22,6 +22,15 @@
const PAGE_STYLES = ["single", "fade", "double", "longstrip"] as const;
type PageStyle = typeof PAGE_STYLES[number];
const MARKER_COLORS: MarkerColor[] = ["yellow", "red", "blue", "green", "purple"];
const MARKER_COLOR_HEX: Record<MarkerColor, string> = {
yellow: "#c4a94a",
red: "#c47a7a",
blue: "#7a9ec4",
green: "#7aab7a",
purple: "#a07ac4",
};
const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>();
@@ -119,6 +128,11 @@
let resumeFading = $state(false);
let resumeVisible = $state(false);
let markerOpen = $state(false);
let markerNote = $state("");
let markerColor: MarkerColor = $state("yellow");
let markerEditId = $state("");
function scheduleResumeDismiss() {
if (resumeTimer) clearTimeout(resumeTimer);
if (resumeFadeTimer) clearTimeout(resumeFadeTimer);
@@ -150,6 +164,16 @@
const currentBookmark = $derived(displayChapter ? store.bookmarks.find(b => b.chapterId === displayChapter!.id) : undefined);
const isBookmarked = $derived(!!currentBookmark && currentBookmark.pageNumber === store.pageNumber);
const currentPageMarkers = $derived(
displayChapter ? store.getMarkersForPage(displayChapter.id, store.pageNumber) : []
);
const activeChapterMarkers = $derived(
displayChapter ? store.getMarkersForChapter(displayChapter.id) : []
);
const hasMarkerOnPage = $derived(currentPageMarkers.length > 0);
const showResumeBanner = $derived(
resumeVisible && resumePage > 1 &&
(style === "longstrip" ? stripResumeReady : store.pageNumber === resumePage)
@@ -239,6 +263,7 @@
visibleChapterId = null;
store.pageUrls = [];
fadingOut = false;
markerOpen = false;
const bookmark = store.bookmarks.find(b => b.chapterId === id);
const resumeTo = bookmark ? bookmark.pageNumber : 0;
@@ -297,7 +322,6 @@
const chId = visibleChapterId;
if (!chId || style !== "longstrip") return;
if (chId === store.activeChapter?.id) return;
// Only resume if this chapter was opened directly, not auto-appended while reading
const wasAppended = untrack(() => stripChapters.findIndex(c => c.chapterId === chId)) > 0;
if (wasAppended) { untrack(() => { resumePage = 0; resumeVisible = false; }); return; }
const bookmark = store.bookmarks.find(b => b.chapterId === chId);
@@ -580,6 +604,55 @@
}
}
function openMarkerPopover() {
const ch = displayChapter;
const manga = store.activeManga;
if (!ch || !manga) return;
if (currentPageMarkers.length > 0) {
const first = currentPageMarkers[0];
markerEditId = first.id;
markerNote = first.note;
markerColor = first.color;
} else {
markerEditId = "";
markerNote = "";
markerColor = "yellow";
}
markerOpen = !markerOpen;
zoomOpen = false;
dlOpen = false;
}
function commitMarker() {
const ch = displayChapter;
const manga = store.activeManga;
if (!ch || !manga) return;
if (markerEditId) {
updateMarker(markerEditId, { note: markerNote.trim(), color: markerColor });
} else {
addMarker({
mangaId: manga.id,
mangaTitle: manga.title,
thumbnailUrl: manga.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: store.pageNumber,
note: markerNote.trim(),
color: markerColor,
});
}
markerOpen = false;
markerNote = "";
markerEditId = "";
}
function deleteCurrentMarker() {
if (markerEditId) removeMarker(markerEditId);
markerOpen = false;
markerNote = "";
markerEditId = "";
}
function cycleStyle() {
const idx = PAGE_STYLES.indexOf(style);
updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] });
@@ -593,9 +666,16 @@
function showUi() {
uiVisible = true;
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(() => uiVisible = false, 3000);
hideTimer = setTimeout(() => { if (!markerOpen) uiVisible = false; }, 3000);
}
$effect(() => {
if (markerOpen) {
uiVisible = true;
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
}
});
function onWheel(e: WheelEvent) {
if (!e.ctrlKey) return;
e.preventDefault();
@@ -607,13 +687,14 @@
}
function onKey(e: KeyboardEvent) {
if ((e.target as HTMLElement).tagName === "INPUT") return;
if ((e.target as HTMLElement).tagName === "INPUT" || (e.target as HTMLElement).tagName === "TEXTAREA") return;
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
const r = store.settings.readingDirection === "rtl";
if (e.key === "Escape") {
e.preventDefault();
if (zoomOpen) { zoomOpen = false; return; }
if (dlOpen) { dlOpen = false; return; }
if (markerOpen) { markerOpen = false; return; }
if (zoomOpen) { zoomOpen = false; return; }
if (dlOpen) { dlOpen = false; return; }
closeReader(); return;
}
if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); adjustZoom(ZOOM_STEP * 2); return; }
@@ -643,6 +724,7 @@
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); toggleBookmark(); }
else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); openMarkerPopover(); }
}
function handleTap(e: MouseEvent) {
@@ -769,16 +851,78 @@
<Download size={14} weight="light" />
</button>
<button class="icon-btn" class:active={isBookmarked} onclick={toggleBookmark} title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
<div class="marker-wrap">
<button
class="icon-btn"
class:active={hasMarkerOnPage}
class:marker-btn-has={hasMarkerOnPage}
onclick={openMarkerPopover}
title={hasMarkerOnPage ? "Edit marker" : "Add marker"}
style={hasMarkerOnPage ? `--marker-color:${MARKER_COLOR_HEX[currentPageMarkers[0].color]}` : ""}
>
<MapPin size={14} weight={hasMarkerOnPage ? "fill" : "regular"} />
</button>
{#if markerOpen}
<div class="marker-popover" role="presentation" onclick={(e) => e.stopPropagation()}
onmouseenter={() => { uiVisible = true; if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }}
>
<div class="marker-pop-header">
<span class="marker-pop-title">
{markerEditId ? "Edit marker" : "New marker"} · p.{store.pageNumber}
</span>
{#if markerEditId}
<button class="marker-delete-btn" onclick={deleteCurrentMarker} title="Delete marker">
<X size={12} weight="light" />
</button>
{/if}
</div>
<div class="marker-color-row">
{#each MARKER_COLORS as c}
<button
class="marker-swatch"
class:marker-swatch-active={markerColor === c}
style="--swatch:{MARKER_COLOR_HEX[c]}"
onclick={() => markerColor = c}
title={c}
>
<span class="swatch-dot"></span>
<span class="swatch-label">{c}</span>
</button>
{/each}
</div>
<textarea
class="marker-textarea"
style="--accent-marker:{MARKER_COLOR_HEX[markerColor]}"
rows={3}
placeholder="Note (optional)…"
bind:value={markerNote}
onmouseenter={() => { uiVisible = true; if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }}
onkeydown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitMarker(); }
if (e.key === "Escape") { markerOpen = false; }
}}
></textarea>
<div class="marker-pop-actions">
<button class="marker-save-btn" style="--accent-marker:{MARKER_COLOR_HEX[markerColor]}" onclick={commitMarker}>
<Check size={12} weight="bold" />
{markerEditId ? "Update" : "Save"}
</button>
<button class="marker-cancel-btn" onclick={() => markerOpen = false}>Cancel</button>
</div>
</div>
{/if}
</div>
<button class="icon-btn" class:active={isBookmarked} onclick={toggleBookmark} title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
</button>
</div>
</div>
{#if showResumeBanner}
<div class="resume-banner" class:fading={resumeFading} role="status" onclick={() => { resumeVisible = false; resumeFading = false; }}>
<span>Bookmark at page {resumePage}</span>
</div>
{/if}
@@ -864,6 +1008,7 @@
<div class="slider-track-bg">
<div class="slider-fill" style="width: {rtl ? 100 - sliderPct : sliderPct}%"></div>
</div>
{#if isBookmarked && currentBookmark}
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
<div
@@ -872,6 +1017,16 @@
title="Bookmark: Page {currentBookmark.pageNumber}"
></div>
{/if}
{#each activeChapterMarkers as m (m.id)}
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
<div
class="slider-checkpoint marker-checkpoint"
style="left: {rtl ? 100 - mPct : mPct}%; background:{MARKER_COLOR_HEX[m.color]}"
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"
></div>
{/each}
<input
type="range"
class="slider-input"
@@ -950,6 +1105,7 @@
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.2; cursor: default; }
.icon-btn.active { color: var(--accent-fg); }
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
.ch-label { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -977,6 +1133,31 @@
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
.marker-wrap { position: relative; flex-shrink: 0; }
.marker-popover { position: absolute; top: calc(100% + 8px); right: 0; width: 240px; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 12px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); z-index: 100; animation: scaleIn 0.1s ease both; transform-origin: top right; }
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; }
.marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--color-error); opacity: 0.55; transition: opacity var(--t-fast), background var(--t-fast); }
.marker-delete-btn:hover { opacity: 1; background: var(--color-error-bg); }
.marker-color-row { display: flex; gap: 5px; }
.marker-swatch { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; padding: 6px 4px 5px; border-radius: var(--radius-md); border: 1px solid transparent; background: none; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.marker-swatch:hover { background: var(--bg-raised); }
.marker-swatch-active { background: var(--bg-overlay); border-color: var(--border-strong); }
.swatch-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--swatch); box-shadow: 0 0 0 0 var(--swatch); transition: box-shadow var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
.marker-swatch:hover .swatch-dot { transform: scale(1.15); }
.marker-swatch-active .swatch-dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--swatch) 30%, transparent); transform: scale(1.1); }
.swatch-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); color: var(--text-faint); text-transform: capitalize; line-height: 1; }
.marker-swatch-active .swatch-label { color: var(--text-muted); }
.marker-textarea { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 7px 9px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base), box-shadow var(--t-base); }
.marker-textarea:focus { border-color: var(--accent-marker, var(--border-focus)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-marker, var(--accent)) 18%, transparent); }
.marker-pop-actions { display: flex; align-items: center; gap: var(--sp-2); }
.marker-save-btn { display: flex; align-items: center; gap: 5px; padding: 6px 14px; border-radius: var(--radius-sm); border: 1px solid color-mix(in srgb, var(--accent-marker, var(--accent)) 50%, transparent); background: color-mix(in srgb, var(--accent-marker, var(--accent)) 15%, transparent); color: var(--accent-marker, var(--accent-fg)); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
.marker-save-btn:hover { filter: brightness(1.2); }
.marker-cancel-btn { flex: 1; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; }
.marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
.viewer:focus { outline: none; }
@@ -1008,6 +1189,7 @@
.slider-input { position: absolute; left: 0; right: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; margin: 0; z-index: 2; }
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); pointer-events: none; z-index: 1; }
.bookmark-checkpoint { background: var(--accent-fg); opacity: 0.7; }
.marker-checkpoint { opacity: 0.85; }
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
.slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
@@ -1015,8 +1197,6 @@
.resume-banner.fading { animation: bannerOut 1s ease forwards; }
@keyframes bannerIn { from { opacity: 0; transform: translateX(-50%) translateY(-6px) scale(0.97); } to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } }
@keyframes bannerOut { from { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } to { opacity: 0; transform: translateX(-50%) translateY(-4px) scale(0.97); } }
.resume-jump { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 2px 8px; cursor: pointer; transition: filter var(--t-fast); }
.resume-jump:hover { filter: brightness(1.15); }
.dl-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-reader) + 10); display: flex; align-items: flex-start; justify-content: flex-end; padding: 48px var(--sp-4) 0; }
.dl-modal { background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-3); min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.6); animation: scaleIn 0.12s ease both; transform-origin: top right; }
+198
View File
@@ -0,0 +1,198 @@
<script lang="ts">
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
import { store, removeMarker, updateMarker, openReader } from "../../store/state.svelte";
import type { MarkerEntry, MarkerColor } from "../../store/state.svelte";
import type { Chapter } from "../../lib/types";
interface Props {
mangaId: number;
chapters: Chapter[];
onClose: () => void;
}
let { mangaId, chapters, onClose }: Props = $props();
const COLOR_HEX: Record<MarkerColor, string> = {
yellow: "#c4a94a",
red: "#c47a7a",
blue: "#7a9ec4",
green: "#7aab7a",
purple: "#a07ac4",
};
const markers = $derived(store.getMarkersForManga(mangaId));
const grouped = $derived.by(() => {
const map = new Map<number, MarkerEntry[]>();
for (const m of markers) {
if (!map.has(m.chapterId)) map.set(m.chapterId, []);
map.get(m.chapterId)!.push(m);
}
const entries = [...map.entries()].map(([chapterId, items]) => ({
chapterId,
chapterName: items[0].chapterName,
items: [...items].sort((a, b) => a.pageNumber - b.pageNumber),
}));
const chapterOrder = new Map(chapters.map((c, i) => [c.id, i]));
entries.sort((a, b) => (chapterOrder.get(a.chapterId) ?? 9999) - (chapterOrder.get(b.chapterId) ?? 9999));
return entries;
});
let editingId: string = $state("");
let editNote: string = $state("");
let editColor: MarkerColor = $state("yellow");
function startEdit(m: MarkerEntry) {
editingId = m.id;
editNote = m.note;
editColor = m.color;
}
function commitEdit() {
if (!editingId) return;
updateMarker(editingId, { note: editNote.trim(), color: editColor });
editingId = "";
}
function jumpToMarker(m: MarkerEntry) {
const chapter = chapters.find(c => c.id === m.chapterId);
if (!chapter) return;
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
openReader(chapter, chaptersAsc);
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
</script>
<div class="panel">
<div class="panel-header">
<div class="panel-title">
<MapPin size={13} weight="fill" />
<span>Markers</span>
{#if markers.length > 0}
<span class="count">{markers.length}</span>
{/if}
</div>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
<div class="panel-body">
{#if grouped.length === 0}
<div class="empty">
<MapPin size={22} weight="light" style="color:var(--text-faint);opacity:0.4" />
<p>No markers yet</p>
<p class="empty-sub">Mark pages while reading with the marker button or keybind</p>
</div>
{:else}
{#each grouped as group}
<div class="group">
<div class="group-header">
<span class="group-name">{group.chapterName}</span>
<span class="group-count">{group.items.length}</span>
</div>
{#each group.items as m (m.id)}
<div class="marker-row" class:editing={editingId === m.id}>
<div class="marker-dot" style="background:{COLOR_HEX[m.color]}"></div>
<div class="marker-body">
{#if editingId === m.id}
<div class="edit-wrap">
<div class="color-row">
{#each Object.entries(COLOR_HEX) as [c, hex]}
<button
class="color-swatch"
class:color-active={editColor === c}
style="background:{hex}"
onclick={() => editColor = c as MarkerColor}
title={c}
></button>
{/each}
</div>
<textarea
class="edit-input"
rows={3}
bind:value={editNote}
placeholder="Add a note…"
onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); } if (e.key === "Escape") editingId = ""; }}
></textarea>
<div class="edit-actions">
<button class="edit-save" onclick={commitEdit}><Check size={12} weight="bold" /> Save</button>
<button class="edit-cancel" onclick={() => editingId = ""}>Cancel</button>
</div>
</div>
{:else}
<button class="marker-jump" onclick={() => jumpToMarker(m)}>
<span class="page-label">p.{m.pageNumber}</span>
{#if m.note}
<span class="marker-note">{m.note}</span>
{:else}
<span class="marker-note marker-note-empty">No note</span>
{/if}
<span class="marker-date">{formatDate(m.updatedAt ?? m.createdAt)}</span>
</button>
<div class="marker-actions">
<button class="marker-action-btn" onclick={() => startEdit(m)} title="Edit"><PencilSimple size={11} weight="light" /></button>
<button class="marker-action-btn danger" onclick={() => removeMarker(m.id)} title="Delete"><Trash size={11} weight="light" /></button>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/each}
{/if}
</div>
</div>
<style>
.panel { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.panel-title { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
.count { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-full); font-size: var(--text-2xs); padding: 0 5px; color: var(--text-faint); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.panel-body { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-1); }
.empty { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-8) var(--sp-4); text-align: center; }
.empty p { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.empty-sub { font-size: var(--text-2xs) !important; opacity: 0.7; max-width: 180px; line-height: var(--leading-snug); }
.group { display: flex; flex-direction: column; gap: 2px; }
.group-header { display: flex; align-items: center; justify-content: space-between; padding: 6px var(--sp-2) 4px; }
.group-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; flex-shrink: 0; }
.marker-row { display: flex; align-items: flex-start; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
.marker-row:hover { background: var(--bg-raised); }
.marker-row.editing { background: var(--bg-raised); }
.marker-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
.marker-body { flex: 1; min-width: 0; display: flex; align-items: flex-start; gap: var(--sp-1); }
.marker-jump { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; text-align: left; background: none; border: none; padding: 0; cursor: pointer; }
.page-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
.marker-note { font-size: var(--text-xs); color: var(--text-secondary); line-height: var(--leading-snug); white-space: pre-wrap; word-break: break-word; }
.marker-note-empty { color: var(--text-faint); font-style: italic; }
.marker-date { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.marker-actions { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; opacity: 0; transition: opacity var(--t-fast); }
.marker-row:hover .marker-actions { opacity: 1; }
.marker-action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
.marker-action-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.marker-action-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
.edit-wrap { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
.color-row { display: flex; gap: 5px; }
.color-swatch { width: 14px; height: 14px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: border-color var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
.color-swatch:hover { transform: scale(1.15); }
.color-active { border-color: var(--text-primary) !important; }
.edit-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base); }
.edit-input:focus { border-color: var(--border-focus); }
.edit-actions { display: flex; align-items: center; gap: var(--sp-2); }
.edit-save { display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
.edit-save:hover { filter: brightness(1.15); }
.edit-cancel { padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.edit-cancel:hover { color: var(--text-muted); border-color: var(--border-strong); }
</style>