mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
[V1] Nix-Based Release Script & History Optimizations
This commit is contained in:
Executable
+184
@@ -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 <version> [--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"
|
||||||
+1
-1
@@ -181,7 +181,7 @@ modules:
|
|||||||
path: .
|
path: .
|
||||||
- type: file
|
- type: file
|
||||||
path: packaging/frontend-dist.tar.gz
|
path: packaging/frontend-dist.tar.gz
|
||||||
sha256: 58b475fccdd41807dfd5e55e236925b9d88ed1df49367fb93f7e463d7ae204d2
|
sha256: ac23bf503533711b19b7fd4b3ec04e081928f2f41b66d8391af1a9e36681548a
|
||||||
- packaging/cargo-sources.json
|
- packaging/cargo-sources.json
|
||||||
- type: inline
|
- type: inline
|
||||||
dest: src-tauri/.cargo
|
dest: src-tauri/.cargo
|
||||||
|
|||||||
Binary file not shown.
Generated
+1
-1
@@ -1797,7 +1797,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"nix",
|
"nix",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "moku"
|
name = "moku"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Moku",
|
"productName": "Moku",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"identifier": "dev.moku.app",
|
"identifier": "dev.moku.app",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
border: 1px solid var(--border-base);
|
border: 1px solid var(--border-base);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: var(--sp-1);
|
padding: var(--sp-1);
|
||||||
min-width: 180px;
|
min-width: 190px;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 4px 16px rgba(0, 0, 0, 0.5),
|
0 0 0 1px rgba(0,0,0,0.08),
|
||||||
0 1px 4px rgba(0, 0, 0, 0.3);
|
0 4px 12px rgba(0,0,0,0.35),
|
||||||
|
0 16px 40px rgba(0,0,0,0.25);
|
||||||
animation: scaleIn 0.1s ease both;
|
animation: scaleIn 0.1s ease both;
|
||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
}
|
}
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px var(--sp-3);
|
padding: 5px var(--sp-2);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -27,29 +28,56 @@
|
|||||||
transition: background var(--t-fast), color var(--t-fast);
|
transition: background var(--t-fast), color var(--t-fast);
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item:hover:not(:disabled) {
|
.item:hover:not(:disabled),
|
||||||
|
.itemFocused:not(:disabled) {
|
||||||
background: var(--bg-overlay);
|
background: var(--bg-overlay);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemDanger { color: var(--color-error); }
|
/* Icon area — fixed-width column so labels align */
|
||||||
.itemDanger:hover:not(:disabled) { background: var(--color-error-bg); color: var(--color-error); }
|
.itemIconWrap {
|
||||||
|
|
||||||
.itemDisabled { opacity: 0.35; cursor: default; }
|
|
||||||
|
|
||||||
.itemIcon {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: inherit;
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
flex-shrink: 0;
|
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 {
|
.separator {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border-dim);
|
background: var(--border-dim);
|
||||||
margin: var(--sp-1) var(--sp-2);
|
margin: 3px var(--sp-1);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import s from "./ContextMenu.module.css";
|
import s from "./ContextMenu.module.css";
|
||||||
|
|
||||||
@@ -30,36 +30,62 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
export default function ContextMenu({ x, y, items, onClose }: Props) {
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [focused, setFocused] = useState<number>(-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(() => {
|
useEffect(() => {
|
||||||
function onDown(e: MouseEvent) {
|
function onDown(e: MouseEvent) {
|
||||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) onClose();
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function onKey(e: KeyboardEvent) {
|
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("mousedown", onDown, true);
|
||||||
document.addEventListener("keydown", onKey, true);
|
document.addEventListener("keydown", onKey, true);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", onDown, true);
|
document.removeEventListener("mousedown", onDown, true);
|
||||||
document.removeEventListener("keydown", onKey, true);
|
document.removeEventListener("keydown", onKey, true);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose, focused, actionable, items]);
|
||||||
|
|
||||||
// Adjust position so menu doesn't clip outside viewport.
|
// Focus first item on open
|
||||||
// Compensate for CSS zoom (applied via document.documentElement.style.zoom)
|
useEffect(() => {
|
||||||
// because clientX/Y are pre-zoom pixels while `position:fixed` is post-zoom.
|
if (actionable.length) setFocused(actionable[0]);
|
||||||
const style = useCallback(() => {
|
}, []);
|
||||||
|
|
||||||
|
const getPosition = useCallback(() => {
|
||||||
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
const zoom = parseFloat(document.documentElement.style.zoom || "1") / 100 || 1;
|
||||||
const scaledX = x / zoom;
|
const scaledX = x / zoom;
|
||||||
const scaledY = y / zoom;
|
const scaledY = y / zoom;
|
||||||
const menuW = 200;
|
const menuW = 200;
|
||||||
const menuH = items.length * 36;
|
const menuH = items.length * 34;
|
||||||
const vw = window.innerWidth / zoom;
|
const vw = window.innerWidth / zoom;
|
||||||
const vh = window.innerHeight / zoom;
|
const vh = window.innerHeight / zoom;
|
||||||
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
|
const left = scaledX + menuW > vw ? scaledX - menuW : scaledX;
|
||||||
@@ -71,7 +97,7 @@ export default function ContextMenu({ x, y, items, onClose }: Props) {
|
|||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className={s.menu}
|
className={s.menu}
|
||||||
style={style()}
|
style={getPosition()}
|
||||||
onContextMenu={(e) => e.preventDefault()}
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{items.map((item, i) => {
|
{items.map((item, i) => {
|
||||||
@@ -79,14 +105,24 @@ export default function ContextMenu({ x, y, items, onClose }: Props) {
|
|||||||
return <div key={i} className={s.separator} />;
|
return <div key={i} className={s.separator} />;
|
||||||
}
|
}
|
||||||
const mi = item as ContextMenuItem;
|
const mi = item as ContextMenuItem;
|
||||||
|
const isFocused = focused === i;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
className={[s.item, mi.danger ? s.itemDanger : "", mi.disabled ? s.itemDisabled : ""].join(" ").trim()}
|
className={[
|
||||||
|
s.item,
|
||||||
|
mi.danger ? s.itemDanger : "",
|
||||||
|
mi.disabled ? s.itemDisabled : "",
|
||||||
|
isFocused ? s.itemFocused : "",
|
||||||
|
].filter(Boolean).join(" ")}
|
||||||
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
onClick={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
|
||||||
|
onMouseEnter={() => !mi.disabled && setFocused(i)}
|
||||||
|
onMouseLeave={() => setFocused(-1)}
|
||||||
disabled={mi.disabled}
|
disabled={mi.disabled}
|
||||||
>
|
>
|
||||||
{mi.icon && <span className={s.itemIcon}>{mi.icon}</span>}
|
<span className={[s.itemIconWrap, mi.danger ? s.itemIconDanger : ""].filter(Boolean).join(" ")}>
|
||||||
|
{mi.icon ?? null}
|
||||||
|
</span>
|
||||||
<span className={s.itemLabel}>{mi.label}</span>
|
<span className={s.itemLabel}>{mi.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,16 +12,25 @@
|
|||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
.headerRight { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
|
||||||
.searchWrap { position: relative; display: flex; align-items: center; }
|
.searchWrap { position: relative; display: flex; align-items: center; }
|
||||||
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
.searchIcon { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||||
.search {
|
.search {
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
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;
|
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
|
||||||
transition: border-color var(--t-base);
|
transition: border-color var(--t-base);
|
||||||
}
|
}
|
||||||
.search::placeholder { color: var(--text-faint); }
|
.search::placeholder { color: var(--text-faint); }
|
||||||
.search:focus { border-color: var(--border-strong); }
|
.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 {
|
.clearBtn {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
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 { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
.row:hover .playIcon { opacity: 1; }
|
.row:hover .playIcon { opacity: 1; }
|
||||||
|
|
||||||
|
/* Thumb with session count badge */
|
||||||
|
.thumbWrap { position: relative; flex-shrink: 0; }
|
||||||
.thumb {
|
.thumb {
|
||||||
width: 36px; height: 52px; border-radius: var(--radius-sm);
|
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);
|
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; }
|
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||||
.mangaTitle {
|
.mangaTitle {
|
||||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||||
@@ -59,11 +81,19 @@
|
|||||||
}
|
}
|
||||||
.chapterName {
|
.chapterName {
|
||||||
font-size: var(--text-sm); color: var(--text-muted);
|
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 {
|
.pageBadge {
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
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 {
|
.time {
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||||
|
|||||||
@@ -1,66 +1,118 @@
|
|||||||
import { useMemo, useState } from "react";
|
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 { thumbUrl } from "../../lib/client";
|
||||||
import { useStore, type HistoryEntry } from "../../store";
|
import { useStore, type HistoryEntry } from "../../store";
|
||||||
import s from "./History.module.css";
|
import s from "./History.module.css";
|
||||||
|
|
||||||
|
// ── Time helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
function timeAgo(ts: number): string {
|
||||||
const diff = Date.now() - ts;
|
const diff = Date.now() - ts;
|
||||||
const m = Math.floor(diff / 60000);
|
const m = Math.floor(diff / 60000);
|
||||||
if (m < 1) return "Just now";
|
if (m < 1) return "Just now";
|
||||||
if (m < 60) return `${m}m ago`;
|
if (m < 60) return `${m}m ago`;
|
||||||
const h = Math.floor(m / 60);
|
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);
|
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" });
|
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group entries by day
|
function dayLabel(ts: number): string {
|
||||||
function groupByDay(entries: HistoryEntry[]): { label: string; items: HistoryEntry[] }[] {
|
const d = new Date(ts);
|
||||||
const groups = new Map<string, HistoryEntry[]>();
|
const now = new Date();
|
||||||
for (const e of entries) {
|
if (d.toDateString() === now.toDateString()) return "Today";
|
||||||
const d = new Date(e.readAt);
|
const yesterday = new Date(now);
|
||||||
const now = new Date();
|
yesterday.setDate(now.getDate() - 1);
|
||||||
let label: string;
|
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
|
||||||
if (d.toDateString() === now.toDateString()) label = "Today";
|
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||||
else {
|
}
|
||||||
const yesterday = new Date(now);
|
|
||||||
yesterday.setDate(now.getDate() - 1);
|
// ── Session grouping ──────────────────────────────────────────────────────────
|
||||||
if (d.toDateString() === yesterday.toDateString()) label = "Yesterday";
|
// Consecutive entries for the same manga within SESSION_GAP_MS are collapsed
|
||||||
else label = d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
// 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<string, ReadingSession[]>();
|
||||||
|
for (const sess of sessions) {
|
||||||
|
const label = dayLabel(sess.readAt);
|
||||||
if (!groups.has(label)) groups.set(label, []);
|
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 }));
|
return Array.from(groups.entries()).map(([label, items]) => ({ label, items }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function History() {
|
export default function History() {
|
||||||
const history = useStore((s) => s.history);
|
const history = useStore((s) => s.history);
|
||||||
const clearHistory = useStore((s) => s.clearHistory);
|
const clearHistory = useStore((s) => s.clearHistory);
|
||||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
const setActiveManga = useStore((s) => s.setActiveManga);
|
||||||
const setNavPage = useStore((s) => s.setNavPage);
|
const setNavPage = useStore((s) => s.setNavPage);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const filtered = useMemo(() =>
|
const filtered = useMemo(() => {
|
||||||
search.trim()
|
const q = search.trim().toLowerCase();
|
||||||
? history.filter((e) =>
|
if (!q) return history;
|
||||||
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
return history.filter(
|
||||||
e.chapterName.toLowerCase().includes(search.toLowerCase()))
|
(e) => e.mangaTitle.toLowerCase().includes(q) || e.chapterName.toLowerCase().includes(q)
|
||||||
: history,
|
);
|
||||||
[history, search]
|
}, [history, search]);
|
||||||
);
|
|
||||||
|
|
||||||
const groups = useMemo(() => groupByDay(filtered), [filtered]);
|
const sessions = useMemo(() => buildSessions(filtered), [filtered]);
|
||||||
|
const groups = useMemo(() => groupSessionsByDay(sessions), [sessions]);
|
||||||
|
|
||||||
function resumeReading(entry: HistoryEntry) {
|
function resumeReading(session: ReadingSession) {
|
||||||
// Navigate to manga detail — user can continue from there
|
setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||||
setActiveManga({
|
|
||||||
id: entry.mangaId,
|
|
||||||
title: entry.mangaTitle,
|
|
||||||
thumbnailUrl: entry.thumbnailUrl,
|
|
||||||
} as any);
|
|
||||||
setNavPage("library");
|
setNavPage("library");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +125,9 @@ export default function History() {
|
|||||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||||
<input className={s.search} placeholder="Search history…"
|
<input className={s.search} placeholder="Search history…"
|
||||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
{search && (
|
||||||
|
<button className={s.searchClear} onClick={() => setSearch("")} title="Clear">×</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
||||||
@@ -85,11 +140,12 @@ export default function History() {
|
|||||||
{history.length === 0 ? (
|
{history.length === 0 ? (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
||||||
<p className={s.emptyText}>No reading history yet.</p>
|
<p className={s.emptyText}>No reading history yet</p>
|
||||||
<p className={s.emptyHint}>Chapters you read will appear here.</p>
|
<p className={s.emptyHint}>Chapters you read will appear here</p>
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : sessions.length === 0 ? (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
|
<Books size={28} weight="light" className={s.emptyIcon} />
|
||||||
<p className={s.emptyText}>No results for "{search}"</p>
|
<p className={s.emptyText}>No results for "{search}"</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -97,20 +153,38 @@ export default function History() {
|
|||||||
{groups.map(({ label, items }) => (
|
{groups.map(({ label, items }) => (
|
||||||
<div key={label} className={s.group}>
|
<div key={label} className={s.group}>
|
||||||
<p className={s.groupLabel}>{label}</p>
|
<p className={s.groupLabel}>{label}</p>
|
||||||
{items.map((entry) => (
|
{items.map((session) => (
|
||||||
<button key={`${entry.chapterId}-${entry.readAt}`}
|
<button
|
||||||
className={s.row} onClick={() => resumeReading(entry)}>
|
key={`${session.latestChapterId}-${session.readAt}`}
|
||||||
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle}
|
className={s.row}
|
||||||
className={s.thumb} />
|
onClick={() => resumeReading(session)}
|
||||||
|
>
|
||||||
|
<div className={s.thumbWrap}>
|
||||||
|
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} className={s.thumb} />
|
||||||
|
{session.chapterCount > 1 && (
|
||||||
|
<span className={s.sessionBadge}>{session.chapterCount}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className={s.info}>
|
<div className={s.info}>
|
||||||
<span className={s.mangaTitle}>{entry.mangaTitle}</span>
|
<span className={s.mangaTitle}>{session.mangaTitle}</span>
|
||||||
<span className={s.chapterName}>{entry.chapterName}
|
<span className={s.chapterName}>
|
||||||
{entry.pageNumber > 1 && (
|
{session.chapterCount > 1 ? (
|
||||||
<span className={s.pageBadge}>p.{entry.pageNumber}</span>
|
<span className={s.chapterRange}>
|
||||||
|
{session.firstChapterName}
|
||||||
|
<span className={s.rangeSep}>→</span>
|
||||||
|
{session.latestChapterName}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{session.latestChapterName}
|
||||||
|
{session.latestPageNumber > 1 && (
|
||||||
|
<span className={s.pageBadge}>p.{session.latestPageNumber}</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={s.time}>{timeAgo(entry.readAt)}</span>
|
<span className={s.time}>{timeAgo(session.readAt)}</span>
|
||||||
<Play size={12} weight="fill" className={s.playIcon} />
|
<Play size={12} weight="fill" className={s.playIcon} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react";
|
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 { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries";
|
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[] {
|
function buildCtxItems(m: Manga): ContextMenuEntry[] {
|
||||||
return [
|
return [
|
||||||
{ label: "Open", onClick: () => setActiveManga(m) },
|
{
|
||||||
|
label: "Open",
|
||||||
|
icon: <BookOpen size={13} weight="light" />,
|
||||||
|
onClick: () => setActiveManga(m),
|
||||||
|
},
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: m.inLibrary ? "Remove from library" : "Add to library",
|
label: m.inLibrary ? "Remove from library" : "Add to library",
|
||||||
|
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||||
danger: m.inLibrary,
|
danger: m.inLibrary,
|
||||||
onClick: () => m.inLibrary
|
onClick: () => m.inLibrary
|
||||||
? removeFromLibrary(m)
|
? removeFromLibrary(m)
|
||||||
@@ -171,9 +176,9 @@ export default function Library() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Delete all downloads",
|
label: "Delete all downloads",
|
||||||
|
icon: <Trash size={13} weight="light" />,
|
||||||
danger: true,
|
danger: true,
|
||||||
disabled: !(m.downloadCount && m.downloadCount > 0),
|
disabled: !(m.downloadCount && m.downloadCount > 0),
|
||||||
icon: <Trash size={13} weight="light" />,
|
|
||||||
onClick: () => deleteAllDownloads(m),
|
onClick: () => deleteAllDownloads(m),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ export default function Reader() {
|
|||||||
const [dlOpen, setDlOpen] = useState(false);
|
const [dlOpen, setDlOpen] = useState(false);
|
||||||
const [zoomOpen, setZoomOpen] = useState(false);
|
const [zoomOpen, setZoomOpen] = useState(false);
|
||||||
const [uiVisible, setUiVisible] = useState(true);
|
const [uiVisible, setUiVisible] = useState(true);
|
||||||
const [markedRead, setMarkedRead] = useState<Set<number>>(new Set());
|
const markedReadRef = useRef<Set<number>>(new Set());
|
||||||
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
||||||
// True only after the first page of the new chapter has been decoded,
|
// True only after the first page of the new chapter has been decoded,
|
||||||
// preventing any flash of the previous chapter's image.
|
// preventing any flash of the previous chapter's image.
|
||||||
@@ -319,6 +319,7 @@ export default function Reader() {
|
|||||||
setLoading(true); setError(null); setPageGroups([]); setPageReady(false);
|
setLoading(true); setError(null); setPageGroups([]); setPageReady(false);
|
||||||
// Reset strip state for new chapter navigation (non-scroll transitions)
|
// Reset strip state for new chapter navigation (non-scroll transitions)
|
||||||
appendedRef.current = new Set();
|
appendedRef.current = new Set();
|
||||||
|
markedReadRef.current = new Set();
|
||||||
|
|
||||||
const targetId = activeChapter.id;
|
const targetId = activeChapter.id;
|
||||||
loadingChapterRef.current = targetId;
|
loadingChapterRef.current = targetId;
|
||||||
@@ -483,11 +484,13 @@ export default function Reader() {
|
|||||||
chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
|
chapterName: activeChapter.name, pageNumber, readAt: Date.now(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (settings.autoMarkRead && pageNumber === lastPage && !markedRead.has(activeChapter.id)) {
|
if (settings.autoMarkRead && pageNumber === lastPage) {
|
||||||
setMarkedRead((p) => new Set(p).add(activeChapter.id));
|
if (!markedReadRef.current.has(activeChapter.id)) {
|
||||||
gql(MARK_CHAPTER_READ, { id: activeChapter.id, isRead: true }).catch(console.error);
|
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 ──────────────────────────────────────────────────────────────
|
// ── Navigation ──────────────────────────────────────────────────────────────
|
||||||
const advanceGroup = useCallback((forward: boolean) => {
|
const advanceGroup = useCallback((forward: boolean) => {
|
||||||
@@ -652,11 +655,10 @@ export default function Reader() {
|
|||||||
if (settings.autoMarkRead) {
|
if (settings.autoMarkRead) {
|
||||||
const prevChunk = strip[strip.indexOf(chunk) - 1];
|
const prevChunk = strip[strip.indexOf(chunk) - 1];
|
||||||
if (prevChunk) {
|
if (prevChunk) {
|
||||||
setMarkedRead((r) => {
|
if (!markedReadRef.current.has(prevChunk.chapterId)) {
|
||||||
if (r.has(prevChunk.chapterId)) return r;
|
markedReadRef.current.add(prevChunk.chapterId);
|
||||||
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
|
gql(MARK_CHAPTER_READ, { id: prevChunk.chapterId, isRead: true }).catch(console.error);
|
||||||
return new Set(r).add(prevChunk.chapterId);
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
ArrowLeft, BookmarkSimple, Download, CheckCircle,
|
ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle,
|
||||||
ArrowSquareOut, BookOpen, CircleNotch, Play,
|
ArrowSquareOut, BookOpen, CircleNotch, Play,
|
||||||
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
|
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
|
||||||
List, SquaresFour, FolderSimplePlus, X, Trash,
|
List, SquaresFour, FolderSimplePlus, X, Trash, DownloadSimple,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import {
|
import {
|
||||||
@@ -277,16 +277,23 @@ export default function SeriesDetail() {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: ch.isRead ? "Mark as unread" : "Mark as read",
|
label: ch.isRead ? "Mark as unread" : "Mark as read",
|
||||||
|
icon: ch.isRead
|
||||||
|
? <Circle size={13} weight="light" />
|
||||||
|
: <CheckCircle size={13} weight="light" />,
|
||||||
onClick: () => markRead(ch.id, !ch.isRead),
|
onClick: () => markRead(ch.id, !ch.isRead),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Mark all above as read",
|
label: "Mark all above as read",
|
||||||
|
icon: <CheckCircle size={13} weight="duotone" />,
|
||||||
onClick: () => markAllAboveRead(indexInSorted),
|
onClick: () => markAllAboveRead(indexInSorted),
|
||||||
disabled: indexInSorted === 0,
|
disabled: indexInSorted === 0,
|
||||||
},
|
},
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: ch.isDownloaded ? "Delete download" : "Download",
|
label: ch.isDownloaded ? "Delete download" : "Download",
|
||||||
|
icon: ch.isDownloaded
|
||||||
|
? <Trash size={13} weight="light" />
|
||||||
|
: <Download size={13} weight="light" />,
|
||||||
onClick: () => ch.isDownloaded
|
onClick: () => ch.isDownloaded
|
||||||
? deleteDownloaded(ch.id)
|
? deleteDownloaded(ch.id)
|
||||||
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
|
: gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error),
|
||||||
@@ -295,6 +302,7 @@ export default function SeriesDetail() {
|
|||||||
{ separator: true },
|
{ separator: true },
|
||||||
{
|
{
|
||||||
label: "Download all from here",
|
label: "Download all from here",
|
||||||
|
icon: <DownloadSimple size={13} weight="light" />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
const fromHere = sortedChapters
|
const fromHere = sortedChapters
|
||||||
.slice(indexInSorted)
|
.slice(indexInSorted)
|
||||||
|
|||||||
@@ -164,6 +164,14 @@ export const useStore = create<Store>()(
|
|||||||
history: [],
|
history: [],
|
||||||
addHistory: (entry) =>
|
addHistory: (entry) =>
|
||||||
set((s) => {
|
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);
|
const deduped = s.history.filter((h) => h.chapterId !== entry.chapterId);
|
||||||
return { history: [entry, ...deduped].slice(0, 300) };
|
return { history: [entry, ...deduped].slice(0, 300) };
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user