mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Nix-Based Release Script & History Optimizations
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<string, HistoryEntry[]>();
|
||||
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<string, ReadingSession[]>();
|
||||
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() {
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input className={s.search} placeholder="Search history…"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button className={s.searchClear} onClick={() => setSearch("")} title="Clear">×</button>
|
||||
)}
|
||||
</div>
|
||||
{history.length > 0 && (
|
||||
<button className={s.clearBtn} onClick={clearHistory} title="Clear all history">
|
||||
@@ -85,11 +140,12 @@ export default function History() {
|
||||
{history.length === 0 ? (
|
||||
<div className={s.empty}>
|
||||
<ClockCounterClockwise size={32} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>No reading history yet.</p>
|
||||
<p className={s.emptyHint}>Chapters you read will appear here.</p>
|
||||
<p className={s.emptyText}>No reading history yet</p>
|
||||
<p className={s.emptyHint}>Chapters you read will appear here</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
) : sessions.length === 0 ? (
|
||||
<div className={s.empty}>
|
||||
<Books size={28} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>No results for "{search}"</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -97,20 +153,38 @@ export default function History() {
|
||||
{groups.map(({ label, items }) => (
|
||||
<div key={label} className={s.group}>
|
||||
<p className={s.groupLabel}>{label}</p>
|
||||
{items.map((entry) => (
|
||||
<button key={`${entry.chapterId}-${entry.readAt}`}
|
||||
className={s.row} onClick={() => resumeReading(entry)}>
|
||||
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle}
|
||||
className={s.thumb} />
|
||||
{items.map((session) => (
|
||||
<button
|
||||
key={`${session.latestChapterId}-${session.readAt}`}
|
||||
className={s.row}
|
||||
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}>
|
||||
<span className={s.mangaTitle}>{entry.mangaTitle}</span>
|
||||
<span className={s.chapterName}>{entry.chapterName}
|
||||
{entry.pageNumber > 1 && (
|
||||
<span className={s.pageBadge}>p.{entry.pageNumber}</span>
|
||||
<span className={s.mangaTitle}>{session.mangaTitle}</span>
|
||||
<span className={s.chapterName}>
|
||||
{session.chapterCount > 1 ? (
|
||||
<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>
|
||||
</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} />
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -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: <BookOpen size={13} weight="light" />,
|
||||
onClick: () => setActiveManga(m),
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: m.inLibrary ? "Remove from library" : "Add to library",
|
||||
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
danger: m.inLibrary,
|
||||
onClick: () => m.inLibrary
|
||||
? removeFromLibrary(m)
|
||||
@@ -171,9 +176,9 @@ export default function Library() {
|
||||
},
|
||||
{
|
||||
label: "Delete all downloads",
|
||||
icon: <Trash size={13} weight="light" />,
|
||||
danger: true,
|
||||
disabled: !(m.downloadCount && m.downloadCount > 0),
|
||||
icon: <Trash size={13} weight="light" />,
|
||||
onClick: () => deleteAllDownloads(m),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<Set<number>>(new Set());
|
||||
const markedReadRef = useRef<Set<number>>(new Set());
|
||||
const [pageGroups, setPageGroups] = useState<number[][]>([]);
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? <Circle size={13} weight="light" />
|
||||
: <CheckCircle size={13} weight="light" />,
|
||||
onClick: () => markRead(ch.id, !ch.isRead),
|
||||
},
|
||||
{
|
||||
label: "Mark all above as read",
|
||||
icon: <CheckCircle size={13} weight="duotone" />,
|
||||
onClick: () => markAllAboveRead(indexInSorted),
|
||||
disabled: indexInSorted === 0,
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: ch.isDownloaded ? "Delete download" : "Download",
|
||||
icon: ch.isDownloaded
|
||||
? <Trash size={13} weight="light" />
|
||||
: <Download size={13} weight="light" />,
|
||||
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: <DownloadSimple size={13} weight="light" />,
|
||||
onClick: () => {
|
||||
const fromHere = sortedChapters
|
||||
.slice(indexInSorted)
|
||||
|
||||
Reference in New Issue
Block a user