diff --git a/templates/book_detail_modals.templ b/templates/book_detail_modals.templ
index fa645bf..043dfcb 100644
--- a/templates/book_detail_modals.templ
+++ b/templates/book_detail_modals.templ
@@ -267,10 +267,50 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
>{ textToString(book.Summary) }
-
-
+
+
+
+
+
+
+
+
+
+ for _, tag := range book.Tags {
+
+ }
+
diff --git a/templates/book_detail_modals_templ.go b/templates/book_detail_modals_templ.go
index 471b134..2e7381f 100644
--- a/templates/book_detail_modals_templ.go
+++ b/templates/book_detail_modals_templ.go
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT.
-// templ: version: v0.3.1001
+// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -354,11 +354,11 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.CoverImagePath.String)
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath.String)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 184, Col: 40}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -367,11 +367,11 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 185, Col: 24}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -385,11 +385,11 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 191, Col: 24}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -403,11 +403,11 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 245, Col: 58}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -416,11 +416,11 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var23 string
- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Author))
+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 251, Col: 74}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -450,448 +450,458 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var26 string
- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tagSliceToString(book.Tags))
- if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `book_detail_modals.templ`, Line: 271, Col: 74}
- }
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
- if templ_7745c5c3_Err != nil {
- return templ_7745c5c3_Err
- }
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" class=\"w-full px-3 py-2 rounded-lg border text-sm\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\">
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" readonly class=\"w-full px-3 py-2 rounded-lg border text-sm opacity-60\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\">
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts
index b1d72e8..9e05909 100644
--- a/web/src/book-detail.ts
+++ b/web/src/book-detail.ts
@@ -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 {
+function collectFormData(editorTags: string[]): Record {
const modal = document.getElementById("metadata-editor-modal");
if (!modal) return {};
@@ -55,6 +56,7 @@ function collectFormData(): Record {
"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 {
}
}
- 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,
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;
});