Three defects from real-world use: - The m4b rewrite assumed every stream survives -map 0 -c copy; files carrying text-ish tracks (the movenc 'text' fourcc) fail the ipod muxer with "Tag text incompatible with output codec id" — exactly the error a write attempt hit in the field. The rewrite now attempts full fidelity first, then retries mapping audio + attached pictures only (-dn -sn): chapters live in the container header (-map_chapters 0) so they survive; every attempt still ffprobe-validates before the atomic rename and the original is never touched on total failure. - The browser confirm() is replaced by an in-app dialog (WriteToFileConfirmDialog, Alpine + templ — no native dialogs). - "Write to file" now SAVES the pending editor edits first (persisted through the same PUT the Save button uses), then writes the file from those DB values, and closes the editor + refreshes the page exactly like Save — previously the endpoint wrote the LAST-SAVED DB values, so edits sitting in the modal never reached the file, and the page didn't visibly update. Test coverage per the owner's bar: EVERY single-file container (MP3, FLAC, M4A, M4B) gets its own runtime-generated fixture, gets written, and the FILE's metadata is then verified independently — ffprobe reads the rewritten container and every mapped key is asserted per format, including the subtitle round-trip split (MP3/FLAC yes, M4A/B no) — and the EPUB/CBZ/sidecar assertions now PARSE the written XML (dc titles/ creators, ComicInfo fields, calibre meta) instead of substring matching. A smart-genre unit test also pins the mixed-blob contract: a recognized shelf wins the genre column even when demoted format words ride along in the same blob.
768 lines
36 KiB
Templ
768 lines
36 KiB
Templ
package templates
|
|
|
|
import (
|
|
"bookhoard/internal/handlers"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
|
<div
|
|
id="progress-sync-modal"
|
|
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
|
style="background-color: var(--surface-overlay);"
|
|
>
|
|
<div
|
|
class="card w-full max-w-4xl my-8 p-6"
|
|
style="box-shadow: var(--shadow-pop);"
|
|
>
|
|
<div class="flex justify-between items-center mb-6">
|
|
<div class="flex items-center gap-3">
|
|
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
@Icon("sync", "h-5 w-5")
|
|
</span>
|
|
<div>
|
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Sync Progress</h2>
|
|
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
@click="hideProgressSyncModal()"
|
|
class="icon-btn"
|
|
aria-label="Close"
|
|
>
|
|
@Icon("close", "h-5 w-5")
|
|
</button>
|
|
</div>
|
|
if book.ActiveConflict != nil {
|
|
<div
|
|
class="mb-6 p-4 rounded-xl flex items-start gap-3"
|
|
style="background-color: color-mix(in srgb, var(--status-warning) 15%, transparent); border: 1px solid var(--status-warning);"
|
|
>
|
|
<span class="shrink-0 mt-0.5" style="color: var(--status-warning);">@Icon("alert", "h-5 w-5")</span>
|
|
<div>
|
|
<p class="font-semibold mb-1" style="color: var(--text-primary);">
|
|
Conflict Detected
|
|
</p>
|
|
<p class="text-sm" style="color: var(--text-secondary);">
|
|
Progress differs between devices. Choose which version to keep.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-4">
|
|
for source, data := range book.ActiveConflict.ConflictData {
|
|
<div class="card p-4">
|
|
<div class="flex justify-between items-start mb-3">
|
|
<div>
|
|
<span class="badge mb-2" style="background-color: var(--accent); color: #fff;">
|
|
{ data.Source }
|
|
</span>
|
|
<p class="text-xs" style="color: var(--text-secondary);">
|
|
{ FormatInTimezone(data.Timestamp, user.Timezone) }
|
|
</p>
|
|
</div>
|
|
<div class="text-right">
|
|
if data.Data["percentage"] != nil {
|
|
<div class="text-3xl font-bold" style="color: var(--accent);">
|
|
{ fmt.Sprintf("%.1f", data.Data["percentage"].(float64) * 100) }%
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
if data.Data["page"] != nil {
|
|
<div class="text-sm" style="color: var(--text-secondary);">
|
|
if data.Data["total_pages"] != nil {
|
|
Page: { fmt.Sprintf("%.0f", data.Data["page"].(float64)) } / { fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)) }
|
|
} else {
|
|
Page: { fmt.Sprintf("%.0f", data.Data["page"].(float64)) }
|
|
}
|
|
</div>
|
|
}
|
|
if data.Data["epubcfi"] != nil {
|
|
<div class="text-xs mt-1" style="color: var(--text-secondary);">
|
|
CFI: { data.Data["epubcfi"].(string) }
|
|
</div>
|
|
}
|
|
<div class="mt-3">
|
|
<button
|
|
@click={"resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')"}
|
|
class="btn btn-primary"
|
|
>
|
|
Keep This
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
} else if book.ReadingProgress != nil {
|
|
<div class="mb-4" style="color: var(--text-secondary);">
|
|
<p>No conflicts detected. Current progress from <strong>{ book.ReadingProgress.LastSyncSource.String }</strong>:</p>
|
|
</div>
|
|
<div class="card p-6 text-center">
|
|
<div class="text-5xl font-bold mb-4" style="color: var(--accent);">
|
|
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64 * 100) }%
|
|
</div>
|
|
<p class="capitalize text-lg mb-2" style="color: var(--text-primary);">
|
|
{ book.ReadingProgress.LastSyncSource.String }
|
|
</p>
|
|
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
|
|
<p style="color: var(--text-secondary);">
|
|
Page { book.ReadingProgress.CurrentPage.Int32 } of { book.ReadingProgress.TotalPages.Int32 }
|
|
</p>
|
|
}
|
|
if book.ReadingProgress.LastReadAt.Valid {
|
|
<p class="text-sm mt-2" style="color: var(--text-secondary);">
|
|
Last read: { FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
|
|
</p>
|
|
}
|
|
</div>
|
|
}
|
|
<div class="flex justify-end space-x-3 mt-6">
|
|
<button
|
|
@click="hideProgressSyncModal()"
|
|
class="btn btn-secondary"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ NotesHighlightsModal(user User, book handlers.MediaDetail, tombstoneDays int) {
|
|
<div
|
|
id="notes-modal"
|
|
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
|
style="background-color: var(--surface-overlay);"
|
|
>
|
|
<div
|
|
class="card w-full max-w-2xl my-8 flex flex-col max-h-[90vh]"
|
|
style="box-shadow: var(--shadow-pop);"
|
|
>
|
|
<div class="flex justify-between items-center p-6 border-b flex-shrink-0" style="border-color: var(--border);">
|
|
<div class="flex items-center gap-3">
|
|
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
@Icon("edit", "h-5 w-5")
|
|
</span>
|
|
<div>
|
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Deleted annotations</h2>
|
|
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title } — restore or purge</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
@click="hideNotesModal()"
|
|
class="icon-btn"
|
|
aria-label="Close"
|
|
>
|
|
@Icon("close", "h-5 w-5")
|
|
</button>
|
|
</div>
|
|
|
|
<div class="p-6 overflow-y-auto flex-1 min-h-0">
|
|
<div class="flex items-center justify-center gap-6 mb-6">
|
|
<div class="text-center">
|
|
<div class="text-3xl font-bold" style="color: var(--accent);">{ book.HighlightsCount }</div>
|
|
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Highlights</div>
|
|
</div>
|
|
<div class="text-center">
|
|
<div class="text-3xl font-bold" style="color: var(--accent);">{ book.NotesCount }</div>
|
|
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Notes</div>
|
|
</div>
|
|
<div class="text-center">
|
|
<div class="text-3xl font-bold" style="color: var(--text-secondary);">{ len(book.DeletedAnnotations) }</div>
|
|
<div class="text-xs uppercase tracking-wide" style="color: var(--text-secondary);">Deleted</div>
|
|
</div>
|
|
</div>
|
|
|
|
if len(book.DeletedAnnotations) == 0 {
|
|
<div class="text-center py-6">
|
|
<p style="color: var(--text-secondary);">No deleted annotations. Highlights, notes, and bookmarks removed on the web or on a synced device appear here for recovery.</p>
|
|
</div>
|
|
} else {
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<h3 class="text-sm font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Recently deleted</h3>
|
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ len(book.DeletedAnnotations) }</span>
|
|
</div>
|
|
<p class="text-xs mb-4" style="color: var(--text-secondary);">
|
|
if tombstoneDays == 1 {
|
|
Deleted entries are kept for 1 day before being removed automatically. Restore returns them to every synced device; Delete permanently removes them immediately.
|
|
} else if tombstoneDays > 1 {
|
|
Deleted entries are kept for { tombstoneDays } days before being removed automatically. Restore returns them to every synced device; Delete permanently removes them immediately.
|
|
} else {
|
|
Deleted entries are kept until manually purged (retention disabled). Restore returns them to every synced device; Delete permanently removes them immediately.
|
|
}
|
|
</p>
|
|
<div class="space-y-3">
|
|
for _, ann := range book.DeletedAnnotations {
|
|
<div class="card p-4" data-deleted-annotation={ ann.ID }>
|
|
<div class="flex justify-between items-start gap-3 mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">{ ann.AnnotationType }</span>
|
|
<span class="text-xs" style="color: var(--text-secondary);">
|
|
{ FormatInTimezone(ann.DeletedAt, user.Timezone) }
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
<button
|
|
@click={"restoreDeletedAnnotation('" + ann.AnnotationType + "', '" + ann.ID + "')"}
|
|
class="btn btn-secondary text-xs px-3 py-1.5"
|
|
>
|
|
@Icon("refresh", "h-3.5 w-3.5")
|
|
Restore
|
|
</button>
|
|
<button
|
|
@click={"purgeDeletedAnnotation('" + ann.AnnotationType + "', '" + ann.ID + "')"}
|
|
class="btn btn-ghost text-xs px-3 py-1.5"
|
|
style="color: var(--status-error);"
|
|
>
|
|
@Icon("close", "h-3.5 w-3.5")
|
|
Delete permanently
|
|
</button>
|
|
</div>
|
|
</div>
|
|
if ann.DisplayText != "" {
|
|
<p class="text-sm line-clamp-3" style="color: var(--text-primary);">{ ann.DisplayText }</p>
|
|
} else {
|
|
<p class="text-sm italic" style="color: var(--text-secondary);">(no text)</p>
|
|
}
|
|
if ann.SecondaryText != "" {
|
|
<p class="text-xs mt-1 line-clamp-2" style="color: var(--text-secondary);">{ ann.SecondaryText }</p>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<div class="flex justify-end p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
|
<button
|
|
@click="hideNotesModal()"
|
|
class="btn btn-primary"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ MetadataEditorModal(book handlers.MediaDetail) {
|
|
<div
|
|
id="metadata-editor-modal"
|
|
class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4"
|
|
style="background-color: var(--surface-overlay);"
|
|
data-write-file-path={ book.FilePath }
|
|
>
|
|
<div
|
|
class="card w-full max-w-5xl my-8 flex flex-col max-h-[90vh]"
|
|
style="box-shadow: var(--shadow-pop);"
|
|
>
|
|
<div class="flex justify-between items-center p-6 border-b flex-shrink-0" style="border-color: var(--border);">
|
|
<div class="flex items-center gap-3">
|
|
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
@Icon("edit", "h-5 w-5")
|
|
</span>
|
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Edit Metadata</h2>
|
|
</div>
|
|
<button
|
|
@click="hideMetadataEditor()"
|
|
class="icon-btn"
|
|
aria-label="Close"
|
|
>
|
|
@Icon("close", "h-5 w-5")
|
|
</button>
|
|
</div>
|
|
<div class="flex flex-col md:flex-row gap-8 p-6 flex-1 min-h-0 overflow-y-auto">
|
|
<div class="flex-shrink-0">
|
|
<div class="w-64 h-96 rounded-xl overflow-hidden mb-3 cursor-pointer relative group"
|
|
style="background-color: var(--bg-primary);"
|
|
@click="triggerCoverUpload()"
|
|
>
|
|
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
|
<img id="metadata-cover-preview"
|
|
src={ book.CoverImagePath.String }
|
|
alt={ book.Title }
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
} else {
|
|
<img id="metadata-cover-preview"
|
|
src="/static/placeholder-book.svg"
|
|
alt={ book.Title }
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
}
|
|
<div class="absolute inset-0 bg-black bg-opacity-40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
<span class="text-white text-sm font-semibold">Click to upload</span>
|
|
</div>
|
|
</div>
|
|
<input type="file" id="cover-upload-input" accept="image/jpeg,image/png,image/webp" class="hidden"
|
|
@change="handleCoverUpload($event)" />
|
|
<div class="w-64 space-y-2">
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost w-full"
|
|
@click="removeCover()"
|
|
x-show="hasExistingCover || newCoverPreview"
|
|
>
|
|
Remove Cover
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex-1 min-w-0 space-y-2">
|
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
|
style="color: var(--text-primary);"
|
|
@click="toggleSection('basic')">
|
|
<span class="font-semibold">Basic Info</span>
|
|
<span class="inline-flex">
|
|
<span x-show="openSections.basic">@Icon("chevron-down", "h-4 w-4")</span>
|
|
<span x-show="!openSections.basic" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
|
</span>
|
|
</button>
|
|
<div x-show="openSections.basic" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Title</label>
|
|
<input type="text" name="title" value={ book.Title } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Subtitle</label>
|
|
<input type="text" name="subtitle" value={ textToString(book.Subtitle) } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Author</label>
|
|
<input type="text" name="authors" value={ authorsFormValue(book.Authors, textToString(book.Author)) } class="input" placeholder="Comma-separated names"/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Description</label>
|
|
<textarea name="description" rows="3" class="input"
|
|
>{ textToString(book.Description) }</textarea>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Summary</label>
|
|
<textarea name="summary" rows="2" class="input"
|
|
>{ textToString(book.Summary) }</textarea>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Tags</label>
|
|
<div class="flex flex-wrap gap-1.5 mb-2">
|
|
<template x-for="(tag, idx) in editorTags" :key="idx">
|
|
<span class="chip">
|
|
<span x-text="tag"></span>
|
|
<button type="button" class="hover:bg-surface-hover rounded leading-none" @click="removeEditorTag(idx)">@Icon("close", "h-3 w-3")</button>
|
|
</span>
|
|
</template>
|
|
</div>
|
|
for _, tag := range book.Tags {
|
|
<span data-editor-tag={ tag } class="hidden"></span>
|
|
}
|
|
<div>
|
|
<input type="text"
|
|
x-model="tagSearch"
|
|
@input.debounce.300ms="searchEditorTags()"
|
|
@keydown="onTagKeydown($event)"
|
|
@blur="hideEditorTagDropdown()"
|
|
placeholder="Add tag..."
|
|
class="input" />
|
|
<div
|
|
x-show="showTagDropdown"
|
|
x-transition
|
|
x-cloak
|
|
class="mt-1 w-full rounded-lg border max-h-48 overflow-y-auto"
|
|
style="background-color: var(--bg-secondary); border-color: var(--border); box-shadow: var(--shadow-card);"
|
|
>
|
|
<template x-for="sug in tagSuggestions" :key="sug.value">
|
|
<button
|
|
type="button"
|
|
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center hover:bg-surface-hover"
|
|
:class="highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''"
|
|
@click="selectEditorTagSuggestion(sug.value)"
|
|
>
|
|
<span x-text="sug.value"></span>
|
|
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Community Rating (0-10)</label>
|
|
<input type="number" name="community_rating" min="0" max="10" step="0.1"
|
|
value={ fmt.Sprintf("%.1f", book.CommunityRating.Float64) }
|
|
class="input" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
|
style="color: var(--text-primary);"
|
|
@click="toggleSection('publication')">
|
|
<span class="font-semibold">Publication</span>
|
|
<span class="inline-flex">
|
|
<span x-show="openSections.publication">@Icon("chevron-down", "h-4 w-4")</span>
|
|
<span x-show="!openSections.publication" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
|
</span>
|
|
</button>
|
|
<div x-show="openSections.publication" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Publisher</label>
|
|
<input type="text" name="publishers" value={ authorsFormValue(book.Publishers, textToString(book.Publisher)) } class="input" placeholder="Comma-separated publishers"/>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Date Published</label>
|
|
<input type="date" name="date_published"
|
|
value={ formatDateForInput(book.DatePublished) }
|
|
class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Edition</label>
|
|
<input type="text" name="edition" value={ textToString(book.Edition) } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Language</label>
|
|
<input type="text" name="language" value={ textToString(book.Language) } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Genre</label>
|
|
<input type="text" name="genre" value={ textToString(book.Genre) } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Copyright Year</label>
|
|
<input type="number" name="copyright_year"
|
|
value={ fmt.Sprintf("%d", book.CopyrightYear.Int32) }
|
|
class="input" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
|
style="color: var(--text-primary);"
|
|
@click="toggleSection('series')">
|
|
<span class="font-semibold">Series</span>
|
|
<span class="inline-flex">
|
|
<span x-show="openSections.series">@Icon("chevron-down", "h-4 w-4")</span>
|
|
<span x-show="!openSections.series" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
|
</span>
|
|
</button>
|
|
<div x-show="openSections.series" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Series</label>
|
|
<input type="text" name="series" value={ textToString(book.Series) } class="input" />
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-3">
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Number</label>
|
|
<input type="number" name="series_number" step="any"
|
|
value={ fmtNumber(book.SeriesNumber.Float64) }
|
|
class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Count</label>
|
|
<input type="number" name="series_count"
|
|
value={ fmt.Sprintf("%d", book.SeriesCount.Int32) }
|
|
class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Volume</label>
|
|
<input type="number" name="volume"
|
|
value={ fmt.Sprintf("%d", book.Volume.Int32) }
|
|
class="input" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
|
style="color: var(--text-primary);"
|
|
@click="toggleSection('identifiers')">
|
|
<span class="font-semibold">Identifiers</span>
|
|
<span class="inline-flex">
|
|
<span x-show="openSections.identifiers">@Icon("chevron-down", "h-4 w-4")</span>
|
|
<span x-show="!openSections.identifiers" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
|
</span>
|
|
</button>
|
|
<div x-show="openSections.identifiers" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">ISBN</label>
|
|
<input type="text" name="isbn" value={ textToString(book.Isbn) } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">ASIN</label>
|
|
<input type="text" name="asin" value={ textToString(book.Asin) } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Goodreads ID</label>
|
|
<input type="text" name="goodreads_id" value={ textToString(book.GoodreadsID) } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">OpenLibrary ID</label>
|
|
<input type="text" name="openlibrary_id" value={ textToString(book.OpenlibraryID) } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Google Books ID</label>
|
|
<input type="text" name="google_books_id" value={ textToString(book.GoogleBooksID) } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Web URL</label>
|
|
<input type="url" name="web_url" value={ textToString(book.WebUrl) } class="input" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
if book.IsAudio() {
|
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
|
style="color: var(--text-primary);"
|
|
@click="toggleSection('audio')">
|
|
<span class="font-semibold">Audio</span>
|
|
<span class="inline-flex">
|
|
<span x-show="openSections.audio">@Icon("chevron-down", "h-4 w-4")</span>
|
|
<span x-show="!openSections.audio" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
|
</span>
|
|
</button>
|
|
<div x-show="openSections.audio" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Narrator(s)</label>
|
|
<input type="text" name="narrators" value={ strings.Join(book.Narrators, ", ") } class="input" placeholder="Comma-separated names"/>
|
|
</div>
|
|
<div>
|
|
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
|
<input type="checkbox" name="abridged" checked={ book.Abridged } class="h-4 w-4 accent-current"/>
|
|
<span>Abridged edition</span>
|
|
</label>
|
|
</div>
|
|
<div>
|
|
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
|
<input type="checkbox" name="explicit" checked={ book.Explicit } class="h-4 w-4 accent-current"/>
|
|
<span>Explicit content</span>
|
|
</label>
|
|
</div>
|
|
if book.AudioDuration() > 0 {
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Duration (scanned)</label>
|
|
<p class="input opacity-60 cursor-default tabular-nums" style="color: var(--text-primary);">{ book.AudioDurationLabel() }</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
|
style="color: var(--text-primary);"
|
|
@click="toggleSection('publication')">
|
|
<span class="font-semibold">Publication Details</span>
|
|
<span class="inline-flex">
|
|
<span x-show="openSections.publication">@Icon("chevron-down", "h-4 w-4")</span>
|
|
<span x-show="!openSections.publication" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
|
</span>
|
|
</button>
|
|
<div x-show="openSections.publication" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Age Rating</label>
|
|
<input type="text" name="age_rating" value={ textToString(book.AgeRating) } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Imprint</label>
|
|
<input type="text" name="imprint" value={ textToString(book.Imprint) } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Scan Information</label>
|
|
<input type="text" name="scan_information" value={ textToString(book.ScanInformation) } class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Metadata Notes</label>
|
|
<textarea name="metadata_notes" rows="2" class="input"
|
|
>{ textToString(book.MetadataNotes) }</textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
if book.ShowComicMetadata() {
|
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
|
style="color: var(--text-primary);"
|
|
@click="toggleSection('comic')">
|
|
<span class="font-semibold">Comic/Manga</span>
|
|
<span class="inline-flex">
|
|
<span x-show="openSections.comic">@Icon("chevron-down", "h-4 w-4")</span>
|
|
<span x-show="!openSections.comic" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
|
</span>
|
|
</button>
|
|
<div x-show="openSections.comic" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
|
<div>
|
|
<label for="manga_type" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Manga Type</label>
|
|
<select id="manga_type" name="manga_type" class="input">
|
|
<option value="unknown" selected={ textToString(book.MangaType) == "unknown" }>Unknown</option>
|
|
<option value="no" selected={ textToString(book.MangaType) == "no" }>No</option>
|
|
<option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option>
|
|
<option value="yes_and_right_to_left" selected={ textToString(book.MangaType) == "yes_and_right_to_left" }>Yes (Right to Left)</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label for="reading_direction" class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Reading Direction</label>
|
|
<select id="reading_direction" name="reading_direction" class="input">
|
|
<option value="auto" selected={ textToString(book.ReadingDirection) == "auto" }>Auto</option>
|
|
<option value="ltr" selected={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</option>
|
|
<option value="rtl" selected={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option>
|
|
<option value="vertical" selected={ textToString(book.ReadingDirection) == "vertical" }>Vertical</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Story Arc</label>
|
|
<input type="text" name="story_arc" value={ textToString(book.StoryArc) } class="input" />
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<input type="checkbox" name="is_black_and_white" id="is_black_and_white"
|
|
if book.IsBlackAndWhite.Bool { checked }
|
|
class="rounded" />
|
|
<label for="is_black_and_white" class="text-sm" style="color: var(--text-secondary);">Black & White</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
<div class="rounded-xl border overflow-hidden" style="border-color: var(--border);">
|
|
<button type="button" class="w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover"
|
|
style="color: var(--text-primary);"
|
|
@click="toggleSection('technical')">
|
|
<span class="font-semibold">Technical</span>
|
|
<span class="inline-flex">
|
|
<span x-show="openSections.technical">@Icon("chevron-down", "h-4 w-4")</span>
|
|
<span x-show="!openSections.technical" x-cloak>@Icon("chevron-right", "h-4 w-4")</span>
|
|
</span>
|
|
</button>
|
|
<div x-show="openSections.technical" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Page Count</label>
|
|
<input type="number" name="page_count"
|
|
value={ fmt.Sprintf("%d", book.PageCount.Int32) }
|
|
class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label>
|
|
<input type="text" name="contributors" value={ stringSliceToString(book.Contributors) } class="input" />
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Format</label>
|
|
<input type="text" value={ textToString(book.MimeType) } readonly
|
|
class="input opacity-60" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Size</label>
|
|
<input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
|
|
class="input opacity-60" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Path</label>
|
|
<input type="text" value={ book.FileLocation } readonly
|
|
class="input opacity-60 font-mono text-xs" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center justify-between p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
@click="rescanBook()"
|
|
:disabled="rescanning"
|
|
class="btn btn-secondary"
|
|
>
|
|
<span x-show="!rescanning" class="inline-flex items-center gap-2">@Icon("sync", "h-4 w-4")<span>Rescan</span></span>
|
|
<svg x-show="rescanning" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
@click="resetMetadata()"
|
|
:disabled="resetting"
|
|
class="btn btn-secondary"
|
|
title="Discard all manual edits and restore scanned metadata"
|
|
>
|
|
<span x-show="!resetting" class="inline-flex items-center gap-2">@Icon("refresh", "h-4 w-4")<span>Reset to Scanned</span></span>
|
|
<svg x-show="resetting" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
@click="writeMetadataToFile()"
|
|
:disabled="writingFile"
|
|
class="btn btn-secondary"
|
|
:title="metadataWriteHint()"
|
|
>
|
|
<span x-show="!writingFile" class="inline-flex items-center gap-2">@Icon("save", "h-4 w-4")<span>Write to file</span></span>
|
|
<svg x-show="writingFile" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="flex space-x-3">
|
|
<button
|
|
@click="hideMetadataEditor()"
|
|
class="btn btn-secondary"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
@click="saveMetadata()"
|
|
class="btn btn-primary"
|
|
>
|
|
@Icon("save", "h-4 w-4")
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
// WriteToFileConfirmDialog is the in-app replacement for the browser
|
|
// confirm() on the metadata editor's "Write to file" action (CSP and
|
|
// UX: no native dialogs). The target-specific copy comes from the
|
|
// bookDetail Alpine component (same scope as the editor modal).
|
|
templ WriteToFileConfirmDialog(book handlers.MediaDetail) {
|
|
<div
|
|
id="write-confirm-dialog"
|
|
x-show="writeConfirmOpen"
|
|
x-cloak
|
|
x-transition.opacity
|
|
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
|
style="background-color: var(--surface-overlay);"
|
|
>
|
|
<div class="card w-full max-w-md p-6" style="box-shadow: var(--shadow-pop);">
|
|
<div class="flex items-center gap-3 mb-4">
|
|
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
@Icon("save", "h-5 w-5")
|
|
</span>
|
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">Write metadata to file?</h3>
|
|
</div>
|
|
<p class="text-sm mb-2" style="color: var(--text-secondary)" x-text="writeConfirmMessage()"></p>
|
|
<p class="text-xs mb-6" style="color: var(--text-secondary)">
|
|
Your pending edits are saved first, then the file is rewritten and re-scanned.
|
|
</p>
|
|
<div class="flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
@click="writeConfirmOpen = false"
|
|
class="btn btn-secondary"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
@click="performWriteToFile()"
|
|
:disabled="writingFile"
|
|
class="btn btn-primary"
|
|
>
|
|
<span x-show="!writingFile" class="inline-flex items-center gap-2">@Icon("save", "h-4 w-4")<span>Save & write</span></span>
|
|
<svg x-show="writingFile" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|