feat(metadata-editor): replace tags text input with badge picker + autocomplete

Replace the comma-separated text input for tags in the metadata editor
with a badge-based tag picker:
- Current tags shown as removable pill badges (✕ button per tag)
- Autocomplete input queries existing tags via shared tag-dropdown module
- Results shown as inline dropdown (not absolute, avoids overflow clipping
  from the modal's overflow-y-auto content area)
- Keyboard navigation: ArrowUp/Down, Enter to select, comma to add new,
  Escape to close
- Supports adding new tags not in the database (type + Enter/comma)
- Hidden data-editor-tag spans seed initial tags from server-side render
- collectFormData() now accepts editorTags array, skips the removed
  tags text input
This commit is contained in:
2026-05-10 16:13:07 -04:00
parent 46e0744802
commit 504f145f64
3 changed files with 440 additions and 312 deletions
+87 -9
View File
@@ -1,6 +1,7 @@
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("/");
@@ -46,7 +47,7 @@ function hideMetadataEditor(): void {
if (modal) modal.classList.add("hidden");
}
function collectFormData(): Record<string, unknown> {
function collectFormData(editorTags: string[]): Record<string, unknown> {
const modal = document.getElementById("metadata-editor-modal");
if (!modal) return {};
@@ -55,6 +56,7 @@ function collectFormData(): Record<string, unknown> {
"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;
@@ -65,13 +67,7 @@ function collectFormData(): Record<string, unknown> {
}
}
const tagsStr = data.tags as string;
if (typeof tagsStr === "string") {
data.tags = tagsStr
.split(",")
.map((t: string) => t.trim())
.filter(Boolean);
}
data.tags = editorTags;
const contribStr = data.contributors as string;
if (typeof contribStr === "string") {
@@ -124,6 +120,13 @@ Alpine.data("bookDetail", () => {
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,
@@ -132,6 +135,11 @@ Alpine.data("bookDetail", () => {
coverFile: null as Blob | null,
coverAction: "keep",
saving: false,
editorTags: initialTags,
tagSearch: "",
tagSuggestions: [] as TagSuggestion[],
showTagDropdown: false,
highlightedTagIndex: -1,
showReaderPlaceholder,
showMetadataEditor,
@@ -237,7 +245,7 @@ Alpine.data("bookDetail", () => {
try {
const mediaId = getMediaId();
const formData = collectFormData();
const formData = collectFormData(this.editorTags);
formData.cover_action = this.coverAction;
if (this.coverFile) {
@@ -291,5 +299,75 @@ Alpine.data("bookDetail", () => {
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;
});