feat(ui): inline conflict resolution with Keep This button
Replace the 'Go to Conflicts Page' link with inline conflict resolution. Each conflict source now has a 'Keep This' button that resolves the conflict directly from the book detail page. - Conflict data now keyed by source name (koreader, web) instead of new/existing, with Source and Timestamp fields - Display percentage scaled correctly (* 100) - Fix page field name from current_page to page - Add conflict resolution JavaScript in book-detail.ts - Add 10-minute cooldown after resolution to prevent re-detection
This commit is contained in:
@@ -37,15 +37,8 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
|||||||
⚠️ Conflict Detected
|
⚠️ Conflict Detected
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm" style="color: var(--text-secondary);">
|
<p class="text-sm" style="color: var(--text-secondary);">
|
||||||
Progress differs between devices. Review the options below and manually resolve via the Conflicts page.
|
Progress differs between devices. Choose which version to keep.
|
||||||
</p>
|
</p>
|
||||||
<a
|
|
||||||
href="/conflicts"
|
|
||||||
class="inline-block mt-3 px-4 py-2 rounded-lg text-sm font-semibold"
|
|
||||||
style="background-color: #f59e0b; color: white; text-decoration: none;"
|
|
||||||
>
|
|
||||||
Go to Conflicts Page →
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
for source, data := range book.ActiveConflict.ConflictData {
|
for source, data := range book.ActiveConflict.ConflictData {
|
||||||
@@ -59,21 +52,27 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
|||||||
class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2"
|
class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2"
|
||||||
style="background-color: var(--accent); color: white;"
|
style="background-color: var(--accent); color: white;"
|
||||||
>
|
>
|
||||||
{ source }
|
{ data.Source }
|
||||||
</span>
|
</span>
|
||||||
<p class="text-xs" style="color: var(--text-secondary);">
|
<p class="text-xs" style="color: var(--text-secondary);">
|
||||||
{ FormatInTimezone(data.Timestamp, user.Timezone) }
|
{ FormatInTimezone(data.Timestamp, user.Timezone) }
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<div class="text-3xl font-bold" style="color: var(--accent);">
|
if data.Data["percentage"] != nil {
|
||||||
{ fmt.Sprintf("%.1f", data.Data["percentage"].(float64)) }%
|
<div class="text-3xl font-bold" style="color: var(--accent);">
|
||||||
</div>
|
{ fmt.Sprintf("%.1f", data.Data["percentage"].(float64) * 100) }%
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
if data.Data["current_page"] != nil {
|
if data.Data["page"] != nil {
|
||||||
<div class="text-sm" style="color: var(--text-secondary);">
|
<div class="text-sm" style="color: var(--text-secondary);">
|
||||||
Page: { fmt.Sprintf("%.0f", data.Data["current_page"].(float64)) } / { fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)) }
|
if data.Data["total_pages"] != nil {
|
||||||
|
Page: { fmt.Sprintf("%.0f", data.Data["page"].(float64)) } / { fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)) }
|
||||||
|
} else {
|
||||||
|
Page: { fmt.Sprintf("%.0f", data.Data["page"].(float64)) }
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
if data.Data["epubcfi"] != nil {
|
if data.Data["epubcfi"] != nil {
|
||||||
@@ -81,6 +80,15 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
|||||||
CFI: { data.Data["epubcfi"].(string) }
|
CFI: { data.Data["epubcfi"].(string) }
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<div class="mt-3">
|
||||||
|
<button
|
||||||
|
@click={"resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')"}
|
||||||
|
class="px-3 py-1.5 rounded-lg text-sm font-semibold"
|
||||||
|
style="background-color: var(--accent); color: white;"
|
||||||
|
>
|
||||||
|
Keep This
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,7 @@ interface MetadataEditorState {
|
|||||||
generateCover(): Promise<void>;
|
generateCover(): Promise<void>;
|
||||||
removeCover(): void;
|
removeCover(): void;
|
||||||
saveMetadata(): Promise<void>;
|
saveMetadata(): Promise<void>;
|
||||||
|
resolveConflict(conflictId: string, winner: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
Alpine.data("bookDetail", () => {
|
Alpine.data("bookDetail", () => {
|
||||||
@@ -149,6 +150,33 @@ Alpine.data("bookDetail", () => {
|
|||||||
hideProgressSyncModal,
|
hideProgressSyncModal,
|
||||||
hideNotesModal,
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
const link = document.getElementById("back-link");
|
const link = document.getElementById("back-link");
|
||||||
if (!link) return;
|
if (!link) return;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user