feat(web): book reviews — markdown, sibling of ratings, full API + page surface
media_reviews table mirrors media_ratings' shape (unique per user+item, cascade deletes, updated_at for LWW) but standalone: a review without a rating must work. Body is RAW MARKDOWN — storage and every payload carry the source; rendering happens only at display sites (same doctrine as notes, same sanitization pipeline: markdown-it + DOMPurify). API: GET/PUT/DELETE /api/media-items/:id/review (PUT upserts; an empty body deletes — clearing via an editor is a delete, not an empty row). Book-detail page payload now carries the review beside the rating. Page: the review renders above the synopsis as a scrolling card (same max-height box as the summary), hydrated client-side through renderNoteHtml; 'Write review'/'Edit review' sits next to the rating stars and opens a raw-markdown editor dialog (save / delete / cancel). sqlc regenerated; schema applied to the dev DB. Live-verified: upsert, get, empty-delete, page markup, and the app round-trip against the dev server.
This commit is contained in:
1 parent
fd6f0e3353
commit
2117ec6181
13 files changed
+751
-352
No files matched your search
@@ -154,6 +154,14 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
class="ml-2 text-xs underline hover:opacity-70"
|
||||
style="color: var(--text-secondary);"
|
||||
>Clear</button>
|
||||
<!-- Review editor: markdown, same doctrine as notes -->
|
||||
<button
|
||||
type="button"
|
||||
@click="openReviewEditor()"
|
||||
class="ml-2 text-xs underline hover:opacity-70"
|
||||
style="color: var(--text-secondary);"
|
||||
x-text="hasReview ? 'Edit review' : 'Write review'"
|
||||
></button>
|
||||
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
|
||||
<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>
|
||||
@@ -214,6 +222,20 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
</div>
|
||||
</div>
|
||||
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pb-10">
|
||||
<!-- My Review (raw markdown hydrated client-side through the
|
||||
notes pipeline — same sanitization as every note display) -->
|
||||
if book.Review != nil && book.Review.Review != "" {
|
||||
<div id="review-raw" class="hidden">{ book.Review.Review }</div>
|
||||
<div id="review-card" class="card p-6 mb-6">
|
||||
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">My Review</h3>
|
||||
<div
|
||||
id="review-body"
|
||||
class="note-rich max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed"
|
||||
style="color: var(--text-secondary)"
|
||||
x-html="reviewHtml"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
<!-- Synopsis -->
|
||||
if book.Description.Valid && book.Description.String != "" {
|
||||
<div class="card p-6 mb-6">
|
||||
@@ -477,6 +499,36 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
if user.Role == "admin" {
|
||||
@MetadataEditorModal(book)
|
||||
}
|
||||
<!-- Review editor: plain textarea over the RAW markdown (what
|
||||
storage and sync carry); rendering happens only on display. -->
|
||||
<div
|
||||
x-show="reviewOpen"
|
||||
x-cloak
|
||||
class="fixed inset-0 flex items-center justify-center z-50 p-4"
|
||||
style="background-color: var(--surface-overlay);"
|
||||
@keydown.escape.window="reviewOpen = false"
|
||||
>
|
||||
<div class="absolute inset-0" @click="reviewOpen = false"></div>
|
||||
<div class="relative card p-6 w-full max-w-2xl">
|
||||
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary)">Edit Review</h3>
|
||||
<textarea
|
||||
x-model="reviewDraft"
|
||||
rows="12"
|
||||
class="w-full rounded-xl border p-3 text-sm font-mono resize-y"
|
||||
style="background-color: var(--bg-primary); border-color: var(--border); color: var(--text-primary);"
|
||||
placeholder="Markdown supported — **bold**, lists, footnotes…"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs" style="color: var(--text-secondary)">Markdown — renders like notes. Saving an empty review deletes it.</p>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button type="button" @click="saveReview()" :disabled="reviewSaving" class="btn btn-primary">
|
||||
<span x-show="!reviewSaving">Save review</span>
|
||||
<span x-show="reviewSaving">Saving…</span>
|
||||
</button>
|
||||
<button type="button" @click="deleteReview()" :disabled="reviewSaving || !hasReview" class="btn btn-secondary" style="color: var(--status-danger, #e5484d);">Delete</button>
|
||||
<button type="button" @click="reviewOpen = false" class="btn btn-secondary ml-auto">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ErrorToast(errorMessage)
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+375
-348
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user