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.
This commit is contained in:
@@ -425,7 +425,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@ProgressSyncModal(user, book)
|
@ProgressSyncModal(user, book)
|
||||||
@NotesHighlightsModal(book)
|
@NotesHighlightsModal(user, book)
|
||||||
@MetadataEditorModal(book)
|
@MetadataEditorModal(book)
|
||||||
@ErrorToast(errorMessage)
|
@ErrorToast(errorMessage)
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -128,25 +128,106 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ NotesHighlightsModal(book handlers.MediaDetail) {
|
templ NotesHighlightsModal(user User, book handlers.MediaDetail) {
|
||||||
<div
|
<div
|
||||||
id="notes-modal"
|
id="notes-modal"
|
||||||
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4"
|
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
||||||
style="background-color: var(--surface-overlay);"
|
style="background-color: var(--surface-overlay);"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="card w-full max-w-2xl p-8 text-center"
|
class="card w-full max-w-2xl my-8 flex flex-col max-h-[90vh]"
|
||||||
style="box-shadow: var(--shadow-pop);"
|
style="box-shadow: var(--shadow-pop);"
|
||||||
>
|
>
|
||||||
<span class="inline-grid place-items-center h-14 w-14 rounded-2xl mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
<div class="flex justify-between items-center p-6 border-b flex-shrink-0" style="border-color: var(--border);">
|
||||||
@Icon("edit", "h-7 w-7")
|
<div class="flex items-center gap-3">
|
||||||
</span>
|
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Notes & Highlights</h2>
|
@Icon("edit", "h-5 w-5")
|
||||||
<p class="mb-2" style="color: var(--text-secondary);">
|
</span>
|
||||||
This book has <strong>{ book.NotesCount }</strong> notes and <strong>{ book.HighlightsCount }</strong> highlights.
|
<div>
|
||||||
</p>
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Notes & Highlights</h2>
|
||||||
<p class="mb-6" style="color: var(--text-secondary);">Feature coming soon!</p>
|
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
|
||||||
<div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="hideNotesModal()"
|
||||||
|
class="icon-btn"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
@Icon("close", "h-5 w-5")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 overflow-y-auto flex-1 min-h-0">
|
||||||
|
<div class="flex items-center justify-center gap-6 mb-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold" style="color: var(--accent);">{ book.HighlightsCount }</div>
|
||||||
|
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Highlights</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold" style="color: var(--accent);">{ book.NotesCount }</div>
|
||||||
|
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Notes</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl font-bold" style="color: var(--text-secondary);">{ len(book.DeletedAnnotations) }</div>
|
||||||
|
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Deleted</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if len(book.DeletedAnnotations) == 0 {
|
||||||
|
<div class="text-center py-6">
|
||||||
|
<p style="color: var(--text-secondary);">No deleted annotations. Highlights, notes, and bookmarks removed on the web or on a synced device appear here for recovery.</p>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Recently deleted</h3>
|
||||||
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ len(book.DeletedAnnotations) }</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs mb-4" style="color: var(--text-secondary);">
|
||||||
|
Deleted entries are kept for the sync retention window before being removed automatically. Restore returns them to every synced device; Delete permanently removes them immediately.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
for _, ann := range book.DeletedAnnotations {
|
||||||
|
<div class="card p-4" data-deleted-annotation={ ann.ID }>
|
||||||
|
<div class="flex justify-between items-start gap-3 mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ ann.AnnotationType }</span>
|
||||||
|
<span class="text-xs" style="color: var(--text-secondary);">
|
||||||
|
{ FormatInTimezone(ann.DeletedAt, user.Timezone) }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
@click={"restoreDeletedAnnotation('" + ann.AnnotationType + "', '" + ann.ID + "')"}
|
||||||
|
class="btn btn-secondary text-xs px-3 py-1.5"
|
||||||
|
>
|
||||||
|
@Icon("refresh", "h-3.5 w-3.5")
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click={"purgeDeletedAnnotation('" + ann.AnnotationType + "', '" + ann.ID + "')"}
|
||||||
|
class="btn btn-ghost text-xs px-3 py-1.5"
|
||||||
|
style="color: var(--status-error);"
|
||||||
|
>
|
||||||
|
@Icon("close", "h-3.5 w-3.5")
|
||||||
|
Delete permanently
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
if ann.DisplayText != "" {
|
||||||
|
<p class="text-sm line-clamp-3" style="color: var(--text-primary);">{ ann.DisplayText }</p>
|
||||||
|
} else {
|
||||||
|
<p class="text-sm italic" style="color: var(--text-secondary);">(no text)</p>
|
||||||
|
}
|
||||||
|
if ann.SecondaryText != "" {
|
||||||
|
<p class="text-xs mt-1 line-clamp-2" style="color: var(--text-secondary);">{ ann.SecondaryText }</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||||
<button
|
<button
|
||||||
@click="hideNotesModal()"
|
@click="hideNotesModal()"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1336,7 +1336,7 @@ func BookDetail(user User, book handlers.MediaDetail, errorMessage string) templ
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = NotesHighlightsModal(book).Render(ctx, templ_7745c5c3_Buffer)
|
templ_7745c5c3_Err = NotesHighlightsModal(user, book).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ interface MetadataEditorState {
|
|||||||
clearRating(): Promise<void>;
|
clearRating(): Promise<void>;
|
||||||
toggleRead(read: boolean): Promise<void>;
|
toggleRead(read: boolean): Promise<void>;
|
||||||
resolveConflict(conflictId: string, winner: string): 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", () => {
|
Alpine.data("bookDetail", () => {
|
||||||
@@ -194,6 +196,65 @@ Alpine.data("bookDetail", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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() {
|
init() {
|
||||||
const ratingAttr = document.body.getAttribute("data-rating");
|
const ratingAttr = document.body.getAttribute("data-rating");
|
||||||
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
|
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user