Files
bookhoard/web/src/book-detail.ts
T
john-okeefe f70579b4fc feat(ui): deleted-annotation history on the book page
Replace the Notes & Highlights 'coming soon' stub with a real modal:
active counts plus a 'Recently deleted' section listing every tombstoned
highlight, note, and bookmark (type badge, deletion time in the user's
timezone, text preview), each with Restore and Delete-permanently
actions. Restore returns the annotation to every synced device; Delete
permanently is confirmed before purging. The list is server-rendered
from MediaDetail.DeletedAnnotations — no fetch on open.

Alpine handlers in book-detail.ts call the new restore/purge endpoints
and reload on success. style.css picks up the line-clamp utilities used
by the text previews.
2026-08-22 13:16:48 -04:00

633 lines
20 KiB
TypeScript

import { Alpine } from "./alpine";
import { showToast } from "./toast";
import { generateCoverBlob } from "./cover-generator";
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
function getMediaId(): string {
const parts = window.location.pathname.split("/");
return parts[parts.length - 1] || "";
}
function getAuthHeader(): string {
const token = localStorage.getItem("token");
return token ? `Bearer ${token}` : "";
}
function showProgressSyncModal(): void {
const modal = document.getElementById("progress-sync-modal");
if (modal) modal.classList.remove("hidden");
}
function showNotesModal(): void {
const modal = document.getElementById("notes-modal");
if (modal) modal.classList.remove("hidden");
}
function hideProgressSyncModal(): void {
const modal = document.getElementById("progress-sync-modal");
if (modal) modal.classList.add("hidden");
}
function hideNotesModal(): void {
const modal = document.getElementById("notes-modal");
if (modal) modal.classList.add("hidden");
}
function showReaderPlaceholder(): void {
showToast("Ebook reader coming soon!", "info");
}
function showMetadataEditor(): void {
const modal = document.getElementById("metadata-editor-modal");
if (modal) modal.classList.remove("hidden");
}
function hideMetadataEditor(): void {
const modal = document.getElementById("metadata-editor-modal");
if (modal) modal.classList.add("hidden");
}
function collectFormData(editorTags: string[]): Record<string, unknown> {
const modal = document.getElementById("metadata-editor-modal");
if (!modal) return {};
const data: Record<string, unknown> = {};
const inputs = modal.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
"input[name], textarea[name], select[name]",
);
for (const el of inputs) {
if (el.name === "tags") continue;
if (el.type === "number") {
const val = parseFloat(el.value);
data[el.name] = isNaN(val) ? 0 : val;
} else if (el.type === "checkbox") {
data[el.name] = (el as HTMLInputElement).checked;
} else {
data[el.name] = el.value;
}
}
data.tags = editorTags;
const contribStr = data.contributors as string;
if (typeof contribStr === "string") {
data.contributors = contribStr
.split(",")
.map((c: string) => c.trim())
.filter(Boolean);
}
return data;
}
export {
showReaderPlaceholder,
showMetadataEditor,
showProgressSyncModal,
showNotesModal,
hideProgressSyncModal,
hideNotesModal,
};
interface MetadataEditorState {
openSections: Record<string, boolean>;
coverGenerating: boolean;
hasExistingCover: boolean;
newCoverPreview: string;
coverFile: Blob | null;
coverAction: string;
saving: boolean;
userRating: number;
ratingHover: number;
ratingSaving: boolean;
conflictId: string;
conflictWinner: string;
readSaving: boolean;
toggleSection(section: string): void;
showMetadataEditor(): void;
hideMetadataEditor(): void;
handleCoverUpload(event: Event): void;
generateCover(): Promise<void>;
removeCover(): void;
saveMetadata(): Promise<void>;
starFill(i: number): string;
ratingText(): string;
setRating(value: number): Promise<void>;
clearRating(): Promise<void>;
toggleRead(read: boolean): Promise<void>;
resolveConflict(conflictId: string, winner: string): Promise<void>;
restoreDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
purgeDeletedAnnotation(annotationType: string, annotationId: string): Promise<void>;
}
Alpine.data("bookDetail", () => {
const coverImg = document.querySelector(
"#metadata-editor-modal img#metadata-cover-preview",
) as HTMLImageElement | null;
const hasCover =
coverImg?.src &&
!coverImg.src.includes("placeholder-book.svg");
const coverPreviewEl = document.querySelector(
".aspect-\\[2\\/3\\] img",
) as HTMLImageElement | null;
const coverSrc = coverPreviewEl?.src || "";
const fileUrl = coverSrc && !coverSrc.includes("placeholder") ? coverSrc : "";
const initialTags: string[] = [];
const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]");
tagBadges.forEach((el) => {
const tag = el.getAttribute("data-editor-tag");
if (tag) initialTags.push(tag);
});
return {
openSections: { basic: true } as Record<string, boolean>,
coverGenerating: false,
hasExistingCover: !!hasCover,
newCoverPreview: "",
coverFile: null as Blob | null,
coverAction: "keep",
saving: false,
userRating: 0,
ratingHover: 0,
ratingSaving: false,
conflictId: "",
conflictWinner: "",
readSaving: false,
editorTags: initialTags,
tagSearch: "",
tagSuggestions: [] as TagSuggestion[],
showTagDropdown: false,
highlightedTagIndex: -1,
showReaderPlaceholder,
showMetadataEditor,
hideMetadataEditor,
showNotesModal,
showProgressSyncModal,
hideProgressSyncModal,
hideNotesModal,
async resolveConflict(conflictId: string, winner: string) {
try {
const resp = await fetch(`/api/conflicts/${conflictId}/resolve`, {
method: "POST",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ winner }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || err.message || "Failed to resolve conflict");
}
hideProgressSyncModal();
showToast("Conflict resolved successfully", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to resolve conflict",
"error",
);
}
},
async restoreDeletedAnnotation(annotationType: string, annotationId: string) {
const mediaId = getMediaId();
try {
const resp = await fetch(
`/api/media-items/${mediaId}/annotations/${annotationId}/restore`,
{
method: "POST",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ annotation_type: annotationType }),
},
);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to restore annotation");
}
showToast("Annotation restored", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to restore annotation",
"error",
);
}
},
async purgeDeletedAnnotation(annotationType: string, annotationId: string) {
if (
!confirm(
"Permanently delete this annotation? This cannot be undone and it will not reappear on any device.",
)
) {
return;
}
const mediaId = getMediaId();
try {
const resp = await fetch(
`/api/media-items/${mediaId}/annotations/${annotationId}?annotation_type=${encodeURIComponent(annotationType)}`,
{
method: "DELETE",
headers: { Authorization: getAuthHeader() },
},
);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to delete annotation");
}
showToast("Annotation permanently deleted", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to delete annotation",
"error",
);
}
},
init() {
const ratingAttr = document.body.getAttribute("data-rating");
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
const conflictId = document.body.getAttribute("data-conflict-id");
const conflictWinner = document.body.getAttribute("data-conflict-winner");
this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
this.conflictWinner =
conflictWinner && conflictWinner !== "null" ? conflictWinner : "";
const link = document.getElementById("back-link");
if (!link) return;
const storageKey = "book_detail_back";
if (document.referrer) {
try {
const ref = new URL(document.referrer);
if (
ref.origin === window.location.origin &&
!ref.pathname.startsWith("/readers/") &&
ref.pathname !== window.location.pathname
) {
sessionStorage.setItem(storageKey, ref.pathname + ref.search);
}
} catch {}
} else {
sessionStorage.removeItem(storageKey);
}
const backUrl = sessionStorage.getItem(storageKey) || "/dashboard";
link.addEventListener("click", (e: Event) => {
e.preventDefault();
sessionStorage.removeItem(storageKey);
window.location.href = backUrl;
});
},
toggleSection(section: string) {
this.openSections[section] = !this.openSections[section];
},
starFill(i: number): string {
const display = this.ratingHover || this.userRating;
if (i * 2 <= display) {
return "color: var(--accent);";
} else if (i * 2 - 1 === display) {
return "background: linear-gradient(90deg, var(--accent) 50%, var(--text-secondary) 50%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;";
}
return "color: var(--text-secondary);";
},
ratingText(): string {
if (this.userRating === 0) return "(not rated)";
return `(${(this.userRating / 2).toFixed(1)} / 5)`;
},
async setRating(value: number) {
if (this.ratingSaving) return;
this.ratingSaving = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/rating`, {
method: "POST",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ rating: value }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to save rating");
}
this.userRating = value;
this.ratingHover = 0;
showToast("Rating saved", "success");
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to save rating",
"error",
);
} finally {
this.ratingSaving = false;
}
},
async clearRating() {
if (this.ratingSaving) return;
this.ratingSaving = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/rating`, {
method: "DELETE",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to clear rating");
}
this.userRating = 0;
this.ratingHover = 0;
showToast("Rating cleared", "success");
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to clear rating",
"error",
);
} finally {
this.ratingSaving = false;
}
},
async toggleRead(read: boolean) {
if (this.readSaving) return;
this.readSaving = true;
const mediaId = getMediaId();
try {
// Clear any active sync conflict first. Resolving arms a 10-minute
// suppression window so the progress write below does not spawn a new
// conflict. The winner only needs to be a valid source key; the final
// read/unread state is set by the progress write that follows.
if (this.conflictId && this.conflictWinner) {
const cr = await fetch(
`/api/conflicts/${this.conflictId}/resolve`,
{
method: "POST",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ winner: this.conflictWinner }),
},
);
// 400 means it was already resolved - treat as no conflict.
if (!cr.ok && cr.status !== 400) {
const err = await cr.json().catch(() => ({}));
throw new Error(
err.error || err.message || "Failed to clear sync conflict",
);
}
}
if (read) {
// Mark as Read: PUT percentage 1.0. (Cannot PUT 0 to unread - the
// server silently ignores percentage < 0.005 when progress > 0.01.)
const resp = await fetch(`/api/media-items/${mediaId}/progress`, {
method: "PUT",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ percentage: 1.0 }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to mark as read");
}
} else {
// Mark as Unread: DELETE the progress row. Notes, highlights and
// ratings are independent and are NOT affected.
const resp = await fetch(`/api/media-items/${mediaId}/progress`, {
method: "DELETE",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to mark as unread");
}
}
showToast(
read ? "Marked as read" : "Marked as unread",
"success",
);
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to update read state",
"error",
);
} finally {
this.readSaving = false;
}
},
handleCoverUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
this.coverFile = file;
this.coverAction = "upload";
const reader = new FileReader();
reader.onload = (e) => {
const preview = document.getElementById(
"metadata-cover-preview",
) as HTMLImageElement;
if (preview && e.target?.result) {
preview.src = e.target.result as string;
}
this.newCoverPreview = e.target?.result as string;
};
reader.readAsDataURL(file);
},
async generateCover() {
this.coverGenerating = true;
try {
const formatGroup =
document
.querySelector('[data-format-group]')
?.getAttribute("data-format-group") || "reflowable";
const blob = await generateCoverBlob(fileUrl, formatGroup);
if (!blob) return;
this.coverFile = blob;
this.coverAction = "upload";
const preview = document.getElementById(
"metadata-cover-preview",
) as HTMLImageElement;
if (preview) {
preview.src = URL.createObjectURL(blob);
}
this.newCoverPreview = URL.createObjectURL(blob);
showToast("Cover generated successfully", "success");
} finally {
this.coverGenerating = false;
}
},
removeCover() {
this.coverAction = "remove";
this.coverFile = null;
this.newCoverPreview = "";
const preview = document.getElementById(
"metadata-cover-preview",
) as HTMLImageElement;
if (preview) {
preview.src = "/static/placeholder-book.svg";
}
},
async saveMetadata() {
if (this.saving) return;
this.saving = true;
try {
const mediaId = getMediaId();
const formData = collectFormData(this.editorTags);
formData.cover_action = this.coverAction;
if (this.coverFile) {
const fd = new FormData();
for (const [key, value] of Object.entries(formData)) {
if (Array.isArray(value)) {
fd.append(key, JSON.stringify(value));
} else if (typeof value === "boolean") {
fd.append(key, value ? "true" : "false");
} else {
fd.append(key, String(value));
}
}
fd.append("cover_file", this.coverFile, "cover.jpg");
const resp = await fetch(`/api/media-items/${mediaId}`, {
method: "PUT",
headers: { Authorization: getAuthHeader() },
body: fd,
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to save metadata");
}
} else {
const resp = await fetch(`/api/media-items/${mediaId}`, {
method: "PUT",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to save metadata");
}
}
hideMetadataEditor();
showToast("Metadata saved successfully", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to save metadata",
"error",
);
} finally {
this.saving = false;
}
},
async searchEditorTags() {
const libraryId = document.body.getAttribute("data-library-id") || "";
if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) {
this.tagSuggestions = [];
this.showTagDropdown = false;
return;
}
const results = await searchTagSuggestions(this.tagSearch, libraryId);
const current = this.editorTags.map((t: string) => t.toLowerCase());
this.tagSuggestions = results.filter((r: TagSuggestion) => !current.includes(r.value.toLowerCase()));
this.showTagDropdown = this.tagSuggestions.length > 0;
this.highlightedTagIndex = -1;
},
addEditorTag(tag: string) {
const trimmed = tag.trim();
if (!trimmed) return;
const lower = trimmed.toLowerCase();
if (this.editorTags.some((t: string) => t.toLowerCase() === lower)) return;
this.editorTags.push(trimmed);
this.tagSearch = "";
this.showTagDropdown = false;
this.tagSuggestions = [];
this.highlightedTagIndex = -1;
},
removeEditorTag(index: number) {
this.editorTags.splice(index, 1);
},
selectEditorTagSuggestion(tag: string) {
this.addEditorTag(tag);
},
hideEditorTagDropdown() {
setTimeout(() => { this.showTagDropdown = false; }, 200);
},
onTagKeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault();
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
this.highlightedTagIndex = (this.highlightedTagIndex + 1) % this.tagSuggestions.length;
}
} else if (event.key === "ArrowUp") {
event.preventDefault();
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
this.highlightedTagIndex = this.highlightedTagIndex <= 0
? this.tagSuggestions.length - 1
: this.highlightedTagIndex - 1;
}
} else if (event.key === "Enter") {
event.preventDefault();
if (this.highlightedTagIndex >= 0 && this.showTagDropdown) {
this.addEditorTag(this.tagSuggestions[this.highlightedTagIndex].value);
} else if (this.tagSearch.trim()) {
this.addEditorTag(this.tagSearch);
}
} else if (event.key === ",") {
event.preventDefault();
if (this.tagSearch.trim()) {
this.addEditorTag(this.tagSearch);
}
} else if (event.key === "Escape") {
this.showTagDropdown = false;
this.tagSuggestions = [];
this.highlightedTagIndex = -1;
}
},
} as MetadataEditorState;
});