mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 19:17:03 +00:00
feat(library): show last edited time on book cards
This commit is contained in:
1 parent
742e42d65b
commit
efd45cf61e
6 files changed
+75
-22
No files matched your search
@@ -1,13 +1,16 @@
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::UNIX_EPOCH;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
#[derive(serde::Serialize)]
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct BookSummary {
|
pub struct BookSummary {
|
||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
author: String,
|
author: String,
|
||||||
corrupt: bool,
|
corrupt: bool,
|
||||||
|
updated_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn corrupt_summary(stem: &str) -> BookSummary {
|
fn corrupt_summary(stem: &str) -> BookSummary {
|
||||||
@@ -16,9 +19,33 @@ fn corrupt_summary(stem: &str) -> BookSummary {
|
|||||||
title: "Unreadable book".to_string(),
|
title: "Unreadable book".to_string(),
|
||||||
author: String::new(),
|
author: String::new(),
|
||||||
corrupt: true,
|
corrupt: true,
|
||||||
|
updated_at: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn file_modified_ms(path: &Path) -> u64 {
|
||||||
|
fs::metadata(path)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.ok()
|
||||||
|
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn latest_chapter_ms(value: &serde_json::Value) -> u64 {
|
||||||
|
value
|
||||||
|
.get("chapters")
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
.map(|chapters| {
|
||||||
|
chapters
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| c.get("updatedAt").and_then(|v| v.as_u64()))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
@@ -81,11 +108,16 @@ pub fn list_books(app: tauri::AppHandle) -> Result<Vec<BookSummary>, String> {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let updated_at = match latest_chapter_ms(&value) {
|
||||||
|
0 => file_modified_ms(&path),
|
||||||
|
ms => ms,
|
||||||
|
};
|
||||||
books.push(BookSummary {
|
books.push(BookSummary {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
corrupt: false,
|
corrupt: false,
|
||||||
|
updated_at,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(books)
|
Ok(books)
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import { RowMenu } from "./RowMenu";
|
|||||||
import { BackupButton } from "./BackupButton";
|
import { BackupButton } from "./BackupButton";
|
||||||
import { useBackup } from "../store/useBackup";
|
import { useBackup } from "../store/useBackup";
|
||||||
import { useBook } from "../store/useBook";
|
import { useBook } from "../store/useBook";
|
||||||
|
import { relativeTime } from "../time";
|
||||||
|
|
||||||
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||||
const [books, setBooks] = useState<BookSummary[]>([]);
|
const [books, setBooks] = useState<BookSummary[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
||||||
const notice = useBook((s) => s.notice);
|
const notice = useBook((s) => s.notice);
|
||||||
@@ -30,6 +32,10 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setNow(Date.now()), 60_000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (restoreNonce) refresh();
|
if (restoreNonce) refresh();
|
||||||
}, [restoreNonce]);
|
}, [restoreNonce]);
|
||||||
@@ -125,6 +131,7 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
>
|
>
|
||||||
<span className="card-title">{b.title || "Untitled"}</span>
|
<span className="card-title">{b.title || "Untitled"}</span>
|
||||||
{b.author && <span className="card-author">{b.author}</span>}
|
{b.author && <span className="card-author">{b.author}</span>}
|
||||||
|
{b.updatedAt > 0 && <span className="card-meta">Edited {relativeTime(b.updatedAt, now)}</span>}
|
||||||
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
<RowMenu label="Book options" className="card-menu" onDelete={() => setPendingDelete(b)} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AddPageMenu } from "./AddPageMenu";
|
|||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { RowMenu } from "./RowMenu";
|
import { RowMenu } from "./RowMenu";
|
||||||
|
import { relativeTime } from "../time";
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
chapter: Chapter;
|
chapter: Chapter;
|
||||||
@@ -19,26 +20,6 @@ interface DropTarget {
|
|||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEdited(ts: number, now: number): string {
|
|
||||||
const diff = Math.max(0, now - ts);
|
|
||||||
const min = 60_000;
|
|
||||||
const hour = 60 * min;
|
|
||||||
const day = 24 * hour;
|
|
||||||
if (diff < min) return "just now";
|
|
||||||
if (diff < hour) {
|
|
||||||
const m = Math.floor(diff / min);
|
|
||||||
return `${m} minute${m === 1 ? "" : "s"} ago`;
|
|
||||||
}
|
|
||||||
if (diff < day) {
|
|
||||||
const h = Math.floor(diff / hour);
|
|
||||||
return `${h} hour${h === 1 ? "" : "s"} ago`;
|
|
||||||
}
|
|
||||||
if (diff < 2 * day) return "yesterday";
|
|
||||||
const d = Math.floor(diff / day);
|
|
||||||
if (d < 7) return `${d} days ago`;
|
|
||||||
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
const chapters = useBook((s) => s.book?.chapters ?? []);
|
const chapters = useBook((s) => s.book?.chapters ?? []);
|
||||||
const activeChapterId = useBook((s) => s.activeChapterId);
|
const activeChapterId = useBook((s) => s.activeChapterId);
|
||||||
@@ -254,7 +235,7 @@ export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{row.chapter.id === activeChapterId && (
|
{row.chapter.id === activeChapterId && (
|
||||||
<span className="meta">Edited {formatEdited(row.chapter.updatedAt, now)}</span>
|
<span className="meta">Edited {relativeTime(row.chapter.updatedAt, now)}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<RowMenu
|
<RowMenu
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface BookSummary {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
updatedAt: number;
|
||||||
corrupt?: boolean;
|
corrupt?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1566,6 +1566,19 @@ body.resizing .sidebar {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 14px;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--t-1);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.card-action {
|
.card-action {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px dashed var(--line-strong);
|
border: 1px dashed var(--line-strong);
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
export function relativeTime(ts: number, now: number): string {
|
||||||
|
const diff = Math.max(0, now - ts);
|
||||||
|
const min = 60_000;
|
||||||
|
const hour = 60 * min;
|
||||||
|
const day = 24 * hour;
|
||||||
|
if (diff < min) return "just now";
|
||||||
|
if (diff < hour) {
|
||||||
|
const m = Math.floor(diff / min);
|
||||||
|
return `${m} minute${m === 1 ? "" : "s"} ago`;
|
||||||
|
}
|
||||||
|
if (diff < day) {
|
||||||
|
const h = Math.floor(diff / hour);
|
||||||
|
return `${h} hour${h === 1 ? "" : "s"} ago`;
|
||||||
|
}
|
||||||
|
if (diff < 2 * day) return "yesterday";
|
||||||
|
const d = Math.floor(diff / day);
|
||||||
|
if (d < 7) return `${d} days ago`;
|
||||||
|
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
Reference in new issue
Block a user