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:
+87
-9
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user