import buttonCSS from "../buttonStyles.js"; class PxmeImageEditor extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }); this._image = null; this._dirty = false; this._allCategories = []; this._allPeople = []; this._pendingAdds = { categories: [], people: [] }; this._pendingRemoves = { categories: [], people: [] }; this._localCategories = []; this._localPeople = []; this._isAdmin = false; } set isAdmin(val) { this._isAdmin = !!val; // Render only if the component already has an image — index.js sets // isAdmin before image, and render() must not fire with no image yet. if (this._image) { this.render(); } } get isAdmin() { return this._isAdmin; } set image(val) { this._image = val; this._localCategories = [...(val?.categories || [])]; this._localPeople = [...(val?.people || [])]; this._pendingAdds = { categories: [], people: [] }; this._pendingRemoves = { categories: [], people: [] }; this._dirty = false; this.render(); } get image() { return this._image; } connectedCallback() { this._fetchSuggestions(); this.render(); } async _fetchSuggestions() { try { const [catRes, pplRes] = await Promise.all([ fetch("/api/categories/"), fetch("/api/people/"), ]); if (catRes.ok) { const data = await catRes.json(); this._allCategories = data.categories || []; } if (pplRes.ok) { const data = await pplRes.json(); this._allPeople = (data.people || []).map((p) => p.name); } } catch (err) { console.error("Failed to fetch suggestions:", err); } } close() { this.remove(); } async save() { const imageId = this._image.id; const requests = []; for (const cat of this._pendingAdds.categories) { requests.push( fetch( `/api/images/${imageId}/categories/${encodeURIComponent(cat)}`, { method: "POST" }, ), ); } for (const person of this._pendingAdds.people) { requests.push( fetch( `/api/images/${imageId}/people/${encodeURIComponent(person)}`, { method: "POST" }, ), ); } for (const cat of this._pendingRemoves.categories) { requests.push( fetch( `/api/images/${imageId}/categories/${encodeURIComponent(cat)}`, { method: "DELETE" }, ), ); } for (const person of this._pendingRemoves.people) { requests.push( fetch( `/api/images/${imageId}/people/${encodeURIComponent(person)}`, { method: "DELETE" }, ), ); } try { await Promise.all(requests); } catch (err) { console.error("Failed to save changes:", err); } if (this._dirty) { this.dispatchEvent( new CustomEvent("image-updated", { detail: { id: this._image.id }, bubbles: true, composed: true, }), ); } this.remove(); } async _redescribe(btn) { const imageId = this._image.id; btn.disabled = true; btn.classList.add("is-loading"); try { const res = await fetch(`/api/images/${imageId}/redescribe`, { method: "POST", }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || `HTTP ${res.status}`); } this.dispatchEvent( new CustomEvent("image-updated", { detail: { id: imageId }, bubbles: true, composed: true, }), ); this.remove(); } catch (err) { console.error("Redescribe failed:", err); btn.disabled = false; btn.classList.remove("is-loading"); } } render() { if (!this._image) return; this.shadowRoot.innerHTML = ""; const style = document.createElement("style"); style.textContent = ` ${buttonCSS} *, *::before, *::after { box-sizing: border-box; } * { margin: 0; } :host { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; font-family: inherit; } .pxme-editor__overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); z-index: 1; } .pxme-editor__dialog { position: relative; z-index: 2; background: var(--pxme-color-surface, #fff); color: var(--pxme-color-dark, #1a1a2e); border-radius: var(--pxme-radius-md, 8px); padding: var(--pxme-space-lg, 24px); max-width: 520px; width: 90vw; max-height: 85vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); } .pxme-editor__header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--pxme-space-md, 16px); } .pxme-editor__title { font-size: var(--pxme-font-lg, 1.125rem); font-weight: var(--pxme-weight-semibold, 600); } .pxme-editor__close { font-size: 1.5rem; } .pxme-editor__close:hover { color: var(--pxme-color-dark, #1a1a2e); } .pxme-editor__thumb { display: block; max-width: 100%; max-height: 200px; border-radius: var(--pxme-radius-md, 8px); margin: 0 auto var(--pxme-space-md, 16px); object-fit: contain; } .pxme-editor__section-label { font-size: var(--pxme-font-sm, 0.875rem); font-weight: var(--pxme-weight-semibold, 600); margin-bottom: var(--pxme-space-xs, 8px); color: var(--pxme-color-secondary, #6b7b8d); text-transform: uppercase; letter-spacing: 0.05em; } .pxme-editor__categories, .pxme-editor__people { margin-bottom: var(--pxme-space-md, 16px); } .pxme-editor__tags { display: flex; flex-wrap: wrap; gap: var(--pxme-space-xs, 8px); margin-bottom: var(--pxme-space-xs, 8px); } .pxme-editor__tag { display: inline-flex; align-items: center; gap: var(--pxme-space-2xs, 4px); background: var(--pxme-color-primary, #1e7aff); color: #fff; padding: var(--pxme-space-2xs, 4px) var(--pxme-space-xs, 8px); border-radius: 9999px; font-size: var(--pxme-font-sm, 0.875rem); } .pxme-editor__tag-remove { background: none; border: none; color: inherit; cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 var(--pxme-space-2xs, 4px); opacity: 0.8; } .pxme-editor__tag-remove:hover { opacity: 1; } .pxme-editor__input-wrapper { position: relative; } .pxme-editor__input-row { display: flex; gap: var(--pxme-space-xs, 8px); } .pxme-editor__input { flex: 1; padding: var(--pxme-space-xs, 8px); border: 1px solid var(--pxme-color-border, #6b7b8d); border-radius: var(--pxme-radius-sm, 4px); font-size: var(--pxme-font-sm, 0.875rem); background: var(--pxme-color-surface, #fff); color: var(--pxme-color-dark, #1a1a2e); } .pxme-editor__input:focus { outline: 2px solid var(--pxme-color-primary, #1e7aff); outline-offset: -1px; } .pxme-editor__add-btn.pxme-btn--primary:hover { background: var(--pxme-color-primary-dark, #0056cc); } .pxme-editor__suggestions { position: absolute; top: 100%; left: 0; right: 0; z-index: 10; background: var(--pxme-color-surface, #fff); border: 1px solid var(--pxme-color-border, #e0e0e0); border-radius: var(--pxme-radius-sm, 4px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); max-height: 160px; overflow-y: auto; margin-top: var(--pxme-space-2xs, 4px); } .pxme-editor__suggestion { padding: var(--pxme-space-xs, 8px); cursor: pointer; font-size: var(--pxme-font-sm, 0.875rem); } .pxme-editor__suggestion:hover { background: var(--pxme-color-primary, #1e7aff); color: #fff; } .pxme-editor__footer { display: flex; justify-content: flex-end; gap: var(--pxme-space-xs, 8px); margin-top: var(--pxme-space-md, 16px); padding-top: var(--pxme-space-md, 16px); border-top: 1px solid var(--pxme-color-border, #e0e0e0); } /* Footer buttons use shared .pxme-btn classes */ /* Redescribe button — uses .pxme-btn--icon with local overrides */ .pxme-editor__redescribe-btn { margin-right: auto; border: 1px solid var(--pxme-color-border, #e0e0e0); } .pxme-editor__redescribe-btn svg { width: 18px; height: 18px; } .pxme-editor__redescribe-btn:hover { color: var(--pxme-color-primary, #1e7aff); border-color: var(--pxme-color-primary, #1e7aff); } border-color: var(--pxme-color-border, #e0e0e0); color: var(--pxme-color-secondary, #6b7b8d); } .pxme-editor__redescribe-btn.is-loading svg { animation: pxme-spin 1s linear infinite; } @keyframes pxme-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } `; this.shadowRoot.appendChild(style); // Overlay const overlay = document.createElement("div"); overlay.className = "pxme-editor__overlay"; overlay.addEventListener("click", () => this.close()); this.shadowRoot.appendChild(overlay); // Dialog const dialog = document.createElement("div"); dialog.className = "pxme-editor__dialog"; // Header const header = document.createElement("div"); header.className = "pxme-editor__header"; const title = document.createElement("h3"); title.className = "pxme-editor__title"; title.textContent = "Edit Image"; const closeBtn = document.createElement("button"); closeBtn.className = "pxme-btn pxme-btn--icon pxme-editor__close"; closeBtn.textContent = "\u00d7"; closeBtn.setAttribute("aria-label", "Close editor"); closeBtn.addEventListener("click", () => this.close()); header.appendChild(title); header.appendChild(closeBtn); dialog.appendChild(header); // Thumbnail if (this._image.thumbnailUri) { const thumb = document.createElement("img"); thumb.className = "pxme-editor__thumb"; thumb.src = this._image.thumbnailUri; thumb.alt = this._image.name || ""; dialog.appendChild(thumb); } // Categories section dialog.appendChild( this._createSection("Categories", this._localCategories, "category"), ); // People section dialog.appendChild( this._createSection("People", this._localPeople, "person"), ); // Footer with Redescribe / Cancel / Save const footer = document.createElement("div"); footer.className = "pxme-editor__footer"; // Redescribe costs GPU time — only admins may re-run it. if (this._isAdmin) { const redescribeBtn = document.createElement("button"); redescribeBtn.className = "pxme-btn pxme-btn--icon pxme-editor__redescribe-btn"; redescribeBtn.title = "Re-run AI description, name, and categories"; redescribeBtn.setAttribute("aria-label", "Redescribe image"); redescribeBtn.innerHTML = '' + '' + '' + '' + '' + ''; redescribeBtn.addEventListener("click", () => this._redescribe(redescribeBtn)); footer.appendChild(redescribeBtn); } const cancelBtn = document.createElement("button"); cancelBtn.className = "pxme-btn pxme-editor__cancel-btn"; cancelBtn.textContent = "Cancel"; cancelBtn.addEventListener("click", () => this.close()); const saveBtn = document.createElement("button"); saveBtn.className = "pxme-btn pxme-btn--primary pxme-editor__save-btn"; saveBtn.textContent = "Save"; saveBtn.addEventListener("click", () => this.save()); footer.appendChild(cancelBtn); footer.appendChild(saveBtn); dialog.appendChild(footer); this.shadowRoot.appendChild(dialog); } _createSection(label, items, type) { const sectionClass = type === "category" ? "pxme-editor__categories" : "pxme-editor__people"; const section = document.createElement("div"); section.className = sectionClass; const labelEl = document.createElement("div"); labelEl.className = "pxme-editor__section-label"; labelEl.textContent = label; section.appendChild(labelEl); const tagsContainer = document.createElement("div"); tagsContainer.className = "pxme-editor__tags"; items.forEach((item) => { tagsContainer.appendChild(this._createTag(item, type)); }); section.appendChild(tagsContainer); const inputWrapper = document.createElement("div"); inputWrapper.className = "pxme-editor__input-wrapper"; const inputRow = document.createElement("div"); inputRow.className = "pxme-editor__input-row"; const input = document.createElement("input"); input.className = "pxme-editor__input"; input.placeholder = type === "category" ? "Add category..." : "Add person..."; const addBtn = document.createElement("button"); addBtn.className = "pxme-btn pxme-btn--primary pxme-editor__add-btn"; addBtn.textContent = "Add"; const suggestions = document.createElement("div"); suggestions.className = "pxme-editor__suggestions"; suggestions.style.display = "none"; addBtn.addEventListener("click", () => { this._handleAdd(input, tagsContainer, type); suggestions.style.display = "none"; }); input.addEventListener("keydown", (e) => { if (e.key === "Enter") { this._handleAdd(input, tagsContainer, type); suggestions.style.display = "none"; } }); input.addEventListener("input", () => { this._updateSuggestions(input, suggestions, tagsContainer, type); }); input.addEventListener("focus", () => { if (input.value.trim()) { this._updateSuggestions(input, suggestions, tagsContainer, type); } }); input.addEventListener("blur", () => { suggestions.style.display = "none"; }); inputRow.appendChild(input); inputRow.appendChild(addBtn); inputWrapper.appendChild(inputRow); inputWrapper.appendChild(suggestions); section.appendChild(inputWrapper); return section; } _createTag(name, type) { const tag = document.createElement("span"); tag.className = "pxme-editor__tag"; tag.textContent = name; const removeBtn = document.createElement("button"); removeBtn.className = "pxme-editor__tag-remove"; removeBtn.textContent = "\u00d7"; removeBtn.setAttribute("aria-label", `Remove ${name}`); removeBtn.addEventListener("click", () => this._handleRemove(name, type, tag), ); tag.appendChild(removeBtn); return tag; } _handleAdd(input, tagsContainer, type) { const value = input.value.trim(); if (!value) return; const list = type === "category" ? this._localCategories : this._localPeople; // Don't add duplicates if (list.includes(value)) { input.value = ""; return; } // Track pending change const pendingRemoves = type === "category" ? this._pendingRemoves.categories : this._pendingRemoves.people; const removeIdx = pendingRemoves.indexOf(value); if (removeIdx !== -1) { // Was previously removed — cancel the remove pendingRemoves.splice(removeIdx, 1); } else { // New add const pendingAdds = type === "category" ? this._pendingAdds.categories : this._pendingAdds.people; pendingAdds.push(value); } list.push(value); tagsContainer.appendChild(this._createTag(value, type)); input.value = ""; this._dirty = true; } _handleRemove(name, type, tagEl) { const list = type === "category" ? this._localCategories : this._localPeople; const idx = list.indexOf(name); if (idx !== -1) list.splice(idx, 1); // Track pending change const pendingAdds = type === "category" ? this._pendingAdds.categories : this._pendingAdds.people; const addIdx = pendingAdds.indexOf(name); if (addIdx !== -1) { // Was a pending add — just cancel it pendingAdds.splice(addIdx, 1); } else { // Was an existing item — add to pending removes const pendingRemoves = type === "category" ? this._pendingRemoves.categories : this._pendingRemoves.people; pendingRemoves.push(name); } tagEl.remove(); this._dirty = true; } _updateSuggestions(input, suggestionsEl, tagsContainer, type) { const query = input.value.trim().toLowerCase(); suggestionsEl.innerHTML = ""; if (!query) { suggestionsEl.style.display = "none"; return; } const allItems = type === "category" ? this._allCategories : this._allPeople; const currentItems = type === "category" ? this._localCategories : this._localPeople; const matches = allItems.filter( (item) => item.toLowerCase().includes(query) && !currentItems.includes(item), ); if (matches.length === 0) { suggestionsEl.style.display = "none"; return; } matches.slice(0, 8).forEach((match) => { const div = document.createElement("div"); div.className = "pxme-editor__suggestion"; div.textContent = match; // Prevent input blur when clicking a suggestion div.addEventListener("mousedown", (e) => e.preventDefault()); div.addEventListener("click", () => { input.value = match; this._handleAdd(input, tagsContainer, type); suggestionsEl.style.display = "none"; }); suggestionsEl.appendChild(div); }); suggestionsEl.style.display = "block"; } } customElements.define("pxme-image-editor", PxmeImageEditor); export default PxmeImageEditor;