diff --git a/build-scripts/release.sh b/build-scripts/release.sh new file mode 100755 index 0000000..4669223 --- /dev/null +++ b/build-scripts/release.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# build-scripts/release.sh +# ───────────────────────────────────────────────────────────────────────────── +# Usage: +# ./build-scripts/release.sh 0.2.0 — full release (AUR + Flatpak) +# ./build-scripts/release.sh 0.2.0 --aur — AUR bin package only +# ./build-scripts/release.sh 0.2.0 --flatpak — Flatpak sources + bundle only +# +# Requires: nix, podman (for AUR .SRCINFO generation in Arch container) + +set -euo pipefail + +# ── Colour helpers ───────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' + +info() { echo -e "${CYAN} →${RESET} $*"; } +success() { echo -e "${GREEN} ✓${RESET} $*"; } +warn() { echo -e "${YELLOW} ⚠${RESET} $*"; } +die() { echo -e "${RED} ✗${RESET} $*" >&2; exit 1; } +section() { echo -e "\n${BOLD}── $* ──${RESET}"; } + +# ── Args ─────────────────────────────────────────────────────────────────────── +[[ $# -lt 1 ]] && die "Usage: $0 [--aur|--flatpak]" + +VERSION="$1" +MODE="${2:-all}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +AUR_DIR="${REPO_ROOT}/../moku-bin" +TARBALL="moku-${VERSION}-x86_64.tar.gz" +FLATPAK_MANIFEST="${REPO_ROOT}/dev.moku.app.yml" + +# ── Sanity checks ────────────────────────────────────────────────────────────── +section "Pre-flight" +command -v nix &>/dev/null || die "nix not found" + +if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then + command -v podman &>/dev/null || die "podman not found — needed for Arch container (makepkg)" + [[ -d "$AUR_DIR" ]] || die "AUR dir not found at $AUR_DIR\nClone it first:\n git clone ssh://aur@aur.archlinux.org/moku-bin.git ../moku-bin" + [[ -f "${AUR_DIR}/PKGBUILD" ]] || die "PKGBUILD not found in $AUR_DIR" +fi +success "OK" + +# ── Bump versions ────────────────────────────────────────────────────────────── +section "Bumping version → ${VERSION}" + +sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" \ + "${REPO_ROOT}/src-tauri/tauri.conf.json" +success "tauri.conf.json → ${VERSION}" + +sed -i "0,/^version = \"[^\"]*\"/s//version = \"${VERSION}\"/" \ + "${REPO_ROOT}/src-tauri/Cargo.toml" +success "Cargo.toml → ${VERSION}" + +# ── Build frontend ───────────────────────────────────────────────────────────── +section "Building frontend" +cd "$REPO_ROOT" +nix develop --command pnpm install --frozen-lockfile +nix develop --command pnpm build +success "Frontend built → dist/" + +# ── Build Rust binary ────────────────────────────────────────────────────────── +section "Building Rust binary" +nix develop --command cargo build --release --manifest-path src-tauri/Cargo.toml + +BINARY="${REPO_ROOT}/src-tauri/target/release/moku" +[[ -f "$BINARY" ]] || die "Binary not found: $BINARY" +success "Binary → $BINARY" + +# ── Flatpak ──────────────────────────────────────────────────────────────────── +if [[ "$MODE" == "all" || "$MODE" == "--flatpak" ]]; then + section "Regenerating cargo-sources.json" + cd "$REPO_ROOT" + nix-shell \ + -p "python311.withPackages(ps: [ ps.aiohttp ps.tomlkit ])" \ + --run "python3 packaging/flatpak-cargo-generator.py src-tauri/Cargo.lock -o packaging/cargo-sources.json" + success "cargo-sources.json updated" + + section "Rebuilding frontend-dist.tar.gz" + tar -czf packaging/frontend-dist.tar.gz -C dist . + FRONTEND_SHA=$(sha256sum packaging/frontend-dist.tar.gz | awk '{print $1}') + success "frontend-dist.tar.gz rebuilt sha256: ${FRONTEND_SHA}" + + # Patch the sha256 in dev.moku.app.yml automatically via a temp script + PATCH_SCRIPT=$(mktemp /tmp/patch-sha256-XXXXXX.py) + cat > "$PATCH_SCRIPT" << PYEOF +import re, sys + +path = "${FLATPAK_MANIFEST}" +new_sha = "${FRONTEND_SHA}" +text = open(path).read() + +pattern = r'(path:\s*packaging/frontend-dist\.tar\.gz\s*\n\s*sha256:\s*)[0-9a-f]+' +replacement = r'\g<1>' + new_sha +updated, n = re.subn(pattern, replacement, text) +if n == 0: + sys.exit("Could not find frontend-dist sha256 in dev.moku.app.yml") +open(path, 'w').write(updated) +PYEOF + nix-shell -p python3 --run "python3 '$PATCH_SCRIPT'" + rm -f "$PATCH_SCRIPT" + success "dev.moku.app.yml sha256 updated" + + section "Building Flatpak bundle" + rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo" + + nix shell nixpkgs#appstream nixpkgs#flatpak-builder --command \ + flatpak-builder \ + --repo="${REPO_ROOT}/repo" \ + --force-clean \ + "${REPO_ROOT}/build-dir" \ + "$FLATPAK_MANIFEST" + + flatpak build-bundle \ + "${REPO_ROOT}/repo" \ + "${REPO_ROOT}/moku.flatpak" \ + dev.moku.app + + # Clean up intermediate build artefacts — keep only moku.flatpak + rm -rf "${REPO_ROOT}/build-dir" "${REPO_ROOT}/repo" + success "moku.flatpak created" +fi + +# ── AUR tarball + PKGBUILD ───────────────────────────────────────────────────── +if [[ "$MODE" == "all" || "$MODE" == "--aur" ]]; then + section "Assembling release tarball" + cd "$REPO_ROOT" + STAGE="release-${VERSION}" + rm -rf "$STAGE" + + install -Dm755 "$BINARY" "${STAGE}/usr/bin/moku" + install -Dm644 packaging/dev.moku.app.desktop "${STAGE}/usr/share/applications/dev.moku.app.desktop" + install -Dm644 src-tauri/icons/32x32.png "${STAGE}/usr/share/icons/hicolor/32x32/apps/dev.moku.app.png" + install -Dm644 src-tauri/icons/128x128.png "${STAGE}/usr/share/icons/hicolor/128x128/apps/dev.moku.app.png" + install -Dm644 "src-tauri/icons/128x128@2x.png" "${STAGE}/usr/share/icons/hicolor/256x256/apps/dev.moku.app.png" + install -Dm644 packaging/dev.moku.app.metainfo.xml "${STAGE}/usr/share/metainfo/dev.moku.app.metainfo.xml" + + tar -czf "$TARBALL" "$STAGE/" + AUR_SHA=$(sha256sum "$TARBALL" | awk '{print $1}') + rm -rf "$STAGE" + success "Tarball: ${TARBALL} sha256: ${AUR_SHA}" + + section "Patching PKGBUILD" + PKGBUILD="${AUR_DIR}/PKGBUILD" + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD" + sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD" + sed -i "s/sha256sums=('[^']*')/sha256sums=('${AUR_SHA}')/" "$PKGBUILD" + success "PKGBUILD patched" + + # Tarball is only needed for the GitHub upload — remind user then it can go + info "Tarball kept at ${REPO_ROOT}/${TARBALL} — upload it to GitHub, then it can be deleted" + + section "Generating .SRCINFO (Arch container)" + # Mount only the AUR dir into a throwaway Arch container and run makepkg + podman run --rm \ + --volume "${AUR_DIR}:/aur:z" \ + --workdir /aur \ + archlinux:latest \ + bash -c " + pacman -Sy --noconfirm pacman >/dev/null 2>&1 + source PKGBUILD + makepkg --printsrcinfo > .SRCINFO + " + success ".SRCINFO generated" + + section "Next steps" + echo "" + echo -e " ${BOLD}1. Upload tarball to GitHub:${RESET}" + echo -e " ${CYAN}gh release create v${VERSION} '${REPO_ROOT}/${TARBALL}' --title 'v${VERSION}' --generate-notes${RESET}" + echo "" + echo -e " ${BOLD}2. Push AUR:${RESET}" + echo -e " ${CYAN}cd ${AUR_DIR}${RESET}" + echo -e " ${CYAN}git add PKGBUILD .SRCINFO${RESET}" + echo -e " ${CYAN}git commit -m 'Update to ${VERSION}'${RESET}" + echo -e " ${CYAN}git push origin master${RESET}" + echo "" + echo -e " ${BOLD}3. Clean up:${RESET}" + echo -e " ${CYAN}rm -f ${REPO_ROOT}/${TARBALL}${RESET}" +fi + +echo "" +success "v${VERSION} ready" \ No newline at end of file diff --git a/dev.moku.app.yml b/dev.moku.app.yml index 0a09554..ff0deb6 100644 --- a/dev.moku.app.yml +++ b/dev.moku.app.yml @@ -181,7 +181,7 @@ modules: path: . - type: file path: packaging/frontend-dist.tar.gz - sha256: 58b475fccdd41807dfd5e55e236925b9d88ed1df49367fb93f7e463d7ae204d2 + sha256: ac23bf503533711b19b7fd4b3ec04e081928f2f41b66d8391af1a9e36681548a - packaging/cargo-sources.json - type: inline dest: src-tauri/.cargo diff --git a/packaging/frontend-dist.tar.gz b/packaging/frontend-dist.tar.gz index 25f5062..192c6ab 100644 Binary files a/packaging/frontend-dist.tar.gz and b/packaging/frontend-dist.tar.gz differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b8ae9d6..b1dd3d0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "moku" -version = "0.1.0" +version = "0.2.0" dependencies = [ "dirs 5.0.1", "nix", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 85308bb..c6bce2f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moku" -version = "0.1.0" +version = "0.2.0" edition = "2021" [lib] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 02a7e5b..cff6da1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Moku", - "version": "0.1.0", + "version": "0.2.0", "identifier": "dev.moku.app", "build": { "frontendDist": "../dist", diff --git a/src/components/context/ContextMenu.module.css b/src/components/context/ContextMenu.module.css index fd630ea..c0513f9 100644 --- a/src/components/context/ContextMenu.module.css +++ b/src/components/context/ContextMenu.module.css @@ -5,10 +5,11 @@ border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); - min-width: 180px; + min-width: 190px; box-shadow: - 0 4px 16px rgba(0, 0, 0, 0.5), - 0 1px 4px rgba(0, 0, 0, 0.3); + 0 0 0 1px rgba(0,0,0,0.08), + 0 4px 12px rgba(0,0,0,0.35), + 0 16px 40px rgba(0,0,0,0.25); animation: scaleIn 0.1s ease both; transform-origin: top left; } @@ -18,7 +19,7 @@ align-items: center; gap: var(--sp-2); width: 100%; - padding: 6px var(--sp-3); + padding: 5px var(--sp-2); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); @@ -27,29 +28,56 @@ transition: background var(--t-fast), color var(--t-fast); border: none; background: none; + outline: none; } -.item:hover:not(:disabled) { +.item:hover:not(:disabled), +.itemFocused:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); } -.itemDanger { color: var(--color-error); } -.itemDanger:hover:not(:disabled) { background: var(--color-error-bg); color: var(--color-error); } - -.itemDisabled { opacity: 0.35; cursor: default; } - -.itemIcon { +/* Icon area — fixed-width column so labels align */ +.itemIconWrap { display: flex; align-items: center; - color: inherit; + justify-content: center; + width: 18px; + height: 18px; flex-shrink: 0; + color: var(--text-faint); + transition: color var(--t-fast); + border-radius: var(--radius-sm); } -.itemLabel { flex: 1; } +.item:hover .itemIconWrap, +.itemFocused .itemIconWrap { + color: var(--text-muted); +} + +.itemLabel { + flex: 1; + line-height: 1.3; +} + +/* Danger variant */ +.itemDanger { color: var(--color-error); } +.itemDanger:hover:not(:disabled), +.itemDanger.itemFocused:not(:disabled) { + background: var(--color-error-bg); + color: var(--color-error); +} +.itemIconDanger { color: var(--color-error) !important; opacity: 0.7; } + +/* Disabled */ +.itemDisabled { + opacity: 0.3; + cursor: default; + pointer-events: none; +} .separator { height: 1px; background: var(--border-dim); - margin: var(--sp-1) var(--sp-2); + margin: 3px var(--sp-1); } \ No newline at end of file diff --git a/src/components/context/ContextMenu.tsx b/src/components/context/ContextMenu.tsx index 9dd648b..cafddd2 100644 --- a/src/components/context/ContextMenu.tsx +++ b/src/components/context/ContextMenu.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from "react"; +import { useEffect, useRef, useCallback, useState } from "react"; import { createPortal } from "react-dom"; import s from "./ContextMenu.module.css"; @@ -30,36 +30,62 @@ interface Props { } export default function ContextMenu({ x, y, items, onClose }: Props) { - const menuRef = useRef(null); + const menuRef = useRef(null); + const [focused, setFocused] = useState(-1); + + // Build list of actionable (non-separator, non-disabled) indices for keyboard nav + const actionable = items + .map((_, i) => i) + .filter((i) => !("separator" in items[i]) && !(items[i] as ContextMenuItem).disabled); - // Close on outside click or Escape useEffect(() => { function onDown(e: MouseEvent) { - if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - onClose(); - } + if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose(); } function onKey(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") { e.stopPropagation(); onClose(); return; } + if (e.key === "ArrowDown") { + e.preventDefault(); + setFocused((prev) => { + const cur = actionable.indexOf(prev); + return actionable[(cur + 1) % actionable.length] ?? actionable[0]; + }); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setFocused((prev) => { + const cur = actionable.indexOf(prev); + return actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0]; + }); + return; + } + if (e.key === "Enter" && focused >= 0) { + e.preventDefault(); + const item = items[focused] as ContextMenuItem; + if (item && !item.disabled) { item.onClick(); onClose(); } + return; + } } - // Use capture so we intercept before other handlers document.addEventListener("mousedown", onDown, true); document.addEventListener("keydown", onKey, true); return () => { document.removeEventListener("mousedown", onDown, true); document.removeEventListener("keydown", onKey, true); }; - }, [onClose]); + }, [onClose, focused, actionable, items]); - // Adjust position so menu doesn't clip outside viewport. - // Compensate for CSS zoom (applied via document.documentElement.style.zoom) - // because clientX/Y are pre-zoom pixels while `position:fixed` is post-zoom. - const style = useCallback(() => { + // Focus first item on open + useEffect(() => { + if (actionable.length) setFocused(actionable[0]); + }, []); + + const getPosition = useCallback(() => { const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1; const scaledX = x / zoom; const scaledY = y / zoom; const menuW = 200; - const menuH = items.length * 36; + const menuH = items.length * 34; const vw = window.innerWidth / zoom; const vh = window.innerHeight / zoom; const left = scaledX + menuW > vw ? scaledX - menuW : scaledX; @@ -71,7 +97,7 @@ export default function ContextMenu({ x, y, items, onClose }: Props) {
e.preventDefault()} > {items.map((item, i) => { @@ -79,14 +105,24 @@ export default function ContextMenu({ x, y, items, onClose }: Props) { return
; } const mi = item as ContextMenuItem; + const isFocused = focused === i; return ( ); diff --git a/src/components/pages/History.module.css b/src/components/pages/History.module.css index 9d8dd26..d387c37 100644 --- a/src/components/pages/History.module.css +++ b/src/components/pages/History.module.css @@ -12,16 +12,25 @@ color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } .headerRight { display: flex; align-items: center; gap: var(--sp-2); } + .searchWrap { position: relative; display: flex; align-items: center; } .searchIcon { position: absolute; left: 9px; 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 26px; + border-radius: var(--radius-md); padding: 5px 28px 5px 26px; 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); } +.searchClear { + position: absolute; right: 7px; + color: var(--text-faint); font-size: 14px; line-height: 1; + background: none; border: none; cursor: pointer; padding: 2px; + transition: color var(--t-base); +} +.searchClear:hover { color: var(--text-muted); } + .clearBtn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); @@ -47,11 +56,24 @@ .row:hover { background: var(--bg-raised); border-color: var(--border-dim); } .row:hover .playIcon { opacity: 1; } +/* Thumb with session count badge */ +.thumbWrap { position: relative; flex-shrink: 0; } .thumb { width: 36px; height: 52px; border-radius: var(--radius-sm); - object-fit: cover; flex-shrink: 0; background: var(--bg-raised); + object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); } +.sessionBadge { + position: absolute; bottom: -4px; right: -6px; + background: var(--accent-muted); border: 1px solid var(--accent-dim); + color: var(--accent-fg); + font-family: var(--font-ui); font-size: 9px; font-weight: 600; + letter-spacing: 0.02em; + padding: 1px 4px; border-radius: 6px; + line-height: 1.4; + pointer-events: none; +} + .info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; } .mangaTitle { font-size: var(--text-base); font-weight: var(--weight-medium); @@ -59,11 +81,19 @@ } .chapterName { font-size: var(--text-sm); color: var(--text-muted); - display: flex; align-items: center; gap: var(--sp-2); + display: flex; align-items: center; gap: var(--sp-1); min-width: 0; +} +.chapterRange { + display: flex; align-items: center; gap: 5px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + color: var(--text-muted); font-size: var(--text-sm); +} +.rangeSep { + color: var(--text-faint); font-size: 10px; flex-shrink: 0; } .pageBadge { font-family: var(--font-ui); font-size: var(--text-2xs); - color: var(--text-faint); letter-spacing: var(--tracking-wide); + color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; } .time { font-family: var(--font-ui); font-size: var(--text-xs); diff --git a/src/components/pages/History.tsx b/src/components/pages/History.tsx index 4f630d9..2342348 100644 --- a/src/components/pages/History.tsx +++ b/src/components/pages/History.tsx @@ -1,66 +1,118 @@ import { useMemo, useState } from "react"; -import { ClockCounterClockwise, Trash, MagnifyingGlass, Play } from "@phosphor-icons/react"; +import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books } from "@phosphor-icons/react"; import { thumbUrl } from "../../lib/client"; import { useStore, type HistoryEntry } from "../../store"; import s from "./History.module.css"; +// ── Time helpers ────────────────────────────────────────────────────────────── + function timeAgo(ts: number): string { const diff = Date.now() - ts; const m = Math.floor(diff / 60000); - if (m < 1) return "Just now"; - if (m < 60) return `${m}m ago`; + if (m < 1) return "Just now"; + if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); - if (h < 24) return `${h}h ago`; + if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); - if (d < 7) return `${d}d ago`; + if (d < 7) return `${d}d ago`; return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); } -// Group entries by day -function groupByDay(entries: HistoryEntry[]): { label: string; items: HistoryEntry[] }[] { - const groups = new Map(); - for (const e of entries) { - const d = new Date(e.readAt); - const now = new Date(); - let label: string; - if (d.toDateString() === now.toDateString()) label = "Today"; - else { - const yesterday = new Date(now); - yesterday.setDate(now.getDate() - 1); - if (d.toDateString() === yesterday.toDateString()) label = "Yesterday"; - else label = d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }); +function dayLabel(ts: number): string { + const d = new Date(ts); + const now = new Date(); + if (d.toDateString() === now.toDateString()) return "Today"; + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (d.toDateString() === yesterday.toDateString()) return "Yesterday"; + return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }); +} + +// ── Session grouping ────────────────────────────────────────────────────────── +// Consecutive entries for the same manga within SESSION_GAP_MS are collapsed +// into one session card showing the chapter range read. + +const SESSION_GAP_MS = 30 * 60 * 1000; // 30 min + +export interface ReadingSession { + mangaId: number; + mangaTitle: string; + thumbnailUrl: string; + latestChapterId: number; + latestChapterName: string; + latestPageNumber: number; + firstChapterName: string; + chapterCount: number; + readAt: number; +} + +function buildSessions(entries: HistoryEntry[]): ReadingSession[] { + if (!entries.length) return []; + const sessions: ReadingSession[] = []; + let i = 0; + while (i < entries.length) { + const anchor = entries[i]; + const group: HistoryEntry[] = [anchor]; + let j = i + 1; + while (j < entries.length) { + const next = entries[j]; + if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) { + group.push(next); + j++; + } else { + break; + } } + const latest = group[0]; + const oldest = group[group.length - 1]; + sessions.push({ + mangaId: latest.mangaId, + mangaTitle: latest.mangaTitle, + thumbnailUrl: latest.thumbnailUrl, + latestChapterId: latest.chapterId, + latestChapterName: latest.chapterName, + latestPageNumber: latest.pageNumber, + firstChapterName: oldest.chapterName, + chapterCount: group.length, + readAt: latest.readAt, + }); + i = j; + } + return sessions; +} + +function groupSessionsByDay(sessions: ReadingSession[]): { label: string; items: ReadingSession[] }[] { + const groups = new Map(); + for (const sess of sessions) { + const label = dayLabel(sess.readAt); if (!groups.has(label)) groups.set(label, []); - groups.get(label)!.push(e); + groups.get(label)!.push(sess); } return Array.from(groups.entries()).map(([label, items]) => ({ label, items })); } +// ── Component ───────────────────────────────────────────────────────────────── + export default function History() { - const history = useStore((s) => s.history); - const clearHistory = useStore((s) => s.clearHistory); + const history = useStore((s) => s.history); + const clearHistory = useStore((s) => s.clearHistory); const setActiveManga = useStore((s) => s.setActiveManga); const setNavPage = useStore((s) => s.setNavPage); const [search, setSearch] = useState(""); - const filtered = useMemo(() => - search.trim() - ? history.filter((e) => - e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || - e.chapterName.toLowerCase().includes(search.toLowerCase())) - : history, - [history, search] - ); + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return history; + return history.filter( + (e) => e.mangaTitle.toLowerCase().includes(q) || e.chapterName.toLowerCase().includes(q) + ); + }, [history, search]); - const groups = useMemo(() => groupByDay(filtered), [filtered]); + const sessions = useMemo(() => buildSessions(filtered), [filtered]); + const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]); - function resumeReading(entry: HistoryEntry) { - // Navigate to manga detail — user can continue from there - setActiveManga({ - id: entry.mangaId, - title: entry.mangaTitle, - thumbnailUrl: entry.thumbnailUrl, - } as any); + function resumeReading(session: ReadingSession) { + setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any); setNavPage("library"); } @@ -73,6 +125,9 @@ export default function History() { setSearch(e.target.value)} /> + {search && ( + + )}
{history.length > 0 && ( ))} diff --git a/src/components/pages/Library.tsx b/src/components/pages/Library.tsx index 49c0347..fde7e2a 100644 --- a/src/components/pages/Library.tsx +++ b/src/components/pages/Library.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react"; -import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash } from "@phosphor-icons/react"; +import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash, BookOpen, BookmarkSimple } from "@phosphor-icons/react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { gql, thumbUrl } from "../../lib/client"; import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries"; @@ -158,10 +158,15 @@ export default function Library() { function buildCtxItems(m: Manga): ContextMenuEntry[] { return [ - { label: "Open", onClick: () => setActiveManga(m) }, + { + label: "Open", + icon: , + onClick: () => setActiveManga(m), + }, { separator: true }, { label: m.inLibrary ? "Remove from library" : "Add to library", + icon: , danger: m.inLibrary, onClick: () => m.inLibrary ? removeFromLibrary(m) @@ -171,9 +176,9 @@ export default function Library() { }, { label: "Delete all downloads", + icon: , danger: true, disabled: !(m.downloadCount && m.downloadCount > 0), - icon: , onClick: () => deleteAllDownloads(m), }, ]; diff --git a/src/components/pages/Reader.tsx b/src/components/pages/Reader.tsx index 0ea9b1d..9025029 100644 --- a/src/components/pages/Reader.tsx +++ b/src/components/pages/Reader.tsx @@ -206,7 +206,7 @@ export default function Reader() { const [dlOpen, setDlOpen] = useState(false); const [zoomOpen, setZoomOpen] = useState(false); const [uiVisible, setUiVisible] = useState(true); - const [markedRead, setMarkedRead] = useState>(new Set()); + const markedReadRef = useRef>(new Set()); const [pageGroups, setPageGroups] = useState([]); // True only after the first page of the new chapter has been decoded, // preventing any flash of the previous chapter's image. @@ -319,6 +319,7 @@ export default function Reader() { setLoading(true); setError(null); setPageGroups([]); setPageReady(false); // Reset strip state for new chapter navigation (non-scroll transitions) appendedRef.current = new Set(); + markedReadRef.current = new Set(); const targetId = activeChapter.id; loadingChapterRef.current = targetId; @@ -483,11 +484,13 @@ export default function Reader() { chapterName: activeChapter.name, pageNumber, readAt: Date.now(), }); } - if (settings.autoMarkRead && pageNumber === lastPage && !markedRead.has(activeChapter.id)) { - setMarkedRead((p) => new Set(p).add(activeChapter.id)); - gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error); + if (settings.autoMarkRead && pageNumber === lastPage) { + if (!markedReadRef.current.has(activeChapter.id)) { + markedReadRef.current.add(activeChapter.id); + gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error); + } } - }, [pageNumber, lastPage, activeChapter?.id]); + }, [pageNumber, lastPage, activeChapter?.id, settings.autoMarkRead]); // ── Navigation ────────────────────────────────────────────────────────────── const advanceGroup = useCallback((forward: boolean) => { @@ -652,11 +655,10 @@ export default function Reader() { if (settings.autoMarkRead) { const prevChunk = strip[strip.indexOf(chunk) - 1]; if (prevChunk) { - setMarkedRead((r) => { - if (r.has(prevChunk.chapterId)) return r; + if (!markedReadRef.current.has(prevChunk.chapterId)) { + markedReadRef.current.add(prevChunk.chapterId); gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error); - return new Set(r).add(prevChunk.chapterId); - }); + } } } } diff --git a/src/components/pages/SeriesDetail.tsx b/src/components/pages/SeriesDetail.tsx index 9ce8ada..3be41dd 100644 --- a/src/components/pages/SeriesDetail.tsx +++ b/src/components/pages/SeriesDetail.tsx @@ -1,9 +1,9 @@ import { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { - ArrowLeft, BookmarkSimple, Download, CheckCircle, + ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, BookOpen, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, - List, SquaresFour, FolderSimplePlus, X, Trash, + List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple, } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { @@ -277,16 +277,23 @@ export default function SeriesDetail() { return [ { label: ch.isRead ? "Mark as unread" : "Mark as read", + icon: ch.isRead + ? + : , onClick: () => markRead(ch.id, !ch.isRead), }, { label: "Mark all above as read", + icon: , onClick: () => markAllAboveRead(indexInSorted), disabled: indexInSorted === 0, }, { separator: true }, { label: ch.isDownloaded ? "Delete download" : "Download", + icon: ch.isDownloaded + ? + : , onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error), @@ -295,6 +302,7 @@ export default function SeriesDetail() { { separator: true }, { label: "Download all from here", + icon: , onClick: () => { const fromHere = sortedChapters .slice(indexInSorted) diff --git a/src/store/index.ts b/src/store/index.ts index 9047dff..3a5babd 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -164,6 +164,14 @@ export const useStore = create()( history: [], addHistory: (entry) => set((s) => { + const existing = s.history.findIndex((h) => h.chapterId === entry.chapterId); + if (existing === 0) { + // Same chapter is already at the top — just update pageNumber and readAt in place + const updated = [...s.history]; + updated[0] = { ...updated[0], pageNumber: entry.pageNumber, readAt: entry.readAt }; + return { history: updated }; + } + // New chapter or chapter not at top — remove old entry, prepend fresh const deduped = s.history.filter((h) => h.chapterId !== entry.chapterId); return { history: [entry, ...deduped].slice(0, 300) }; }),