feat(frontend): wire metadata editor Alpine component in book-detail.ts
Replace placeholder toast with full metadata editor Alpine data component: - Modal show/hide (showMetadataEditor, hideMetadataEditor) - Accordion section toggle - Cover upload via FileReader preview - Cover generation via dynamic cover-generator import - Cover removal with placeholder fallback - saveMetadata(): collects form data, sends PUT as JSON or multipart depending on whether a cover file is present - Back button fix: skip overwriting sessionStorage back URL when referrer is the current page (preserves navigation after page reload)
This commit is contained in:
+265
-26
@@ -1,56 +1,295 @@
|
|||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
|
import { generateCoverBlob } from "./cover-generator";
|
||||||
|
|
||||||
|
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 {
|
function showReaderPlaceholder(): void {
|
||||||
showToast("Ebook reader coming soon!", "info");
|
showToast("Ebook reader coming soon!", "info");
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMetadataEditorPlaceholder(): void {
|
function showMetadataEditor(): void {
|
||||||
showToast("Metadata editor coming soon!", "info");
|
const modal = document.getElementById("metadata-editor-modal");
|
||||||
|
if (modal) modal.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function showProgressSyncModal(): void {
|
function hideMetadataEditor(): void {
|
||||||
const modal = document.getElementById("progress-sync-modal");
|
const modal = document.getElementById("metadata-editor-modal");
|
||||||
if (modal) {
|
if (modal) modal.classList.add("hidden");
|
||||||
modal.classList.remove("hidden");
|
}
|
||||||
|
|
||||||
|
function collectFormData(): 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.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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showNotesModal(): void {
|
const tagsStr = data.tags as string;
|
||||||
const modal = document.getElementById("notes-modal");
|
if (typeof tagsStr === "string") {
|
||||||
if (modal) {
|
data.tags = tagsStr
|
||||||
modal.classList.remove("hidden");
|
.split(",")
|
||||||
}
|
.map((t: string) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideProgressSyncModal(): void {
|
const contribStr = data.contributors as string;
|
||||||
const modal = document.getElementById("progress-sync-modal");
|
if (typeof contribStr === "string") {
|
||||||
if (modal) {
|
data.contributors = contribStr
|
||||||
modal.classList.add("hidden");
|
.split(",")
|
||||||
}
|
.map((c: string) => c.trim())
|
||||||
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideNotesModal(): void {
|
return data;
|
||||||
const modal = document.getElementById("notes-modal");
|
|
||||||
if (modal) {
|
|
||||||
modal.classList.add("hidden");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
showReaderPlaceholder,
|
showReaderPlaceholder,
|
||||||
showMetadataEditorPlaceholder,
|
showMetadataEditor,
|
||||||
showProgressSyncModal,
|
showProgressSyncModal,
|
||||||
showNotesModal,
|
showNotesModal,
|
||||||
hideProgressSyncModal,
|
hideProgressSyncModal,
|
||||||
hideNotesModal,
|
hideNotesModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
Alpine.data("bookDetail", () => ({
|
interface MetadataEditorState {
|
||||||
|
openSections: Record<string, boolean>;
|
||||||
|
coverGenerating: boolean;
|
||||||
|
hasExistingCover: boolean;
|
||||||
|
newCoverPreview: string;
|
||||||
|
coverFile: Blob | null;
|
||||||
|
coverAction: string;
|
||||||
|
saving: boolean;
|
||||||
|
toggleSection(section: string): void;
|
||||||
|
showMetadataEditor(): void;
|
||||||
|
hideMetadataEditor(): void;
|
||||||
|
handleCoverUpload(event: Event): void;
|
||||||
|
generateCover(): Promise<void>;
|
||||||
|
removeCover(): void;
|
||||||
|
saveMetadata(): 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 : "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
openSections: { basic: true } as Record<string, boolean>,
|
||||||
|
coverGenerating: false,
|
||||||
|
hasExistingCover: !!hasCover,
|
||||||
|
newCoverPreview: "",
|
||||||
|
coverFile: null as Blob | null,
|
||||||
|
coverAction: "keep",
|
||||||
|
saving: false,
|
||||||
|
|
||||||
showReaderPlaceholder,
|
showReaderPlaceholder,
|
||||||
showMetadataEditorPlaceholder,
|
showMetadataEditor,
|
||||||
showProgressSyncModal,
|
hideMetadataEditor,
|
||||||
showNotesModal,
|
showNotesModal,
|
||||||
|
showProgressSyncModal,
|
||||||
hideProgressSyncModal,
|
hideProgressSyncModal,
|
||||||
hideNotesModal,
|
hideNotesModal,
|
||||||
}));
|
|
||||||
|
init() {
|
||||||
|
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];
|
||||||
|
},
|
||||||
|
|
||||||
|
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();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as MetadataEditorState;
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user