Compare commits

..

3 Commits

Author SHA1 Message Date
root
7009b24568 fix: repair notification system (broken type assertion + wrong PocketBase filter quotes)
All checks were successful
Release / Test backend (push) Successful in 55s
Release / Check ui (push) Successful in 2m0s
Release / Docker (push) Successful in 7m19s
Release / Gitea Release (push) Successful in 26s
- Add bookstore.NotificationStore interface and wire it directly to *storage.Store
  in Dependencies, bypassing the Asynq wrapper that caused Producer.(*storage.Store)
  to fail with a 500 on every notification endpoint when Redis is configured
- Replace s.deps.Producer.(*storage.Store) type assertion in all 5 notification
  handlers with s.deps.NotificationStore (nil-safe, always works)
- Fix PocketBase filter single-quote bug in ListNotifications, ClearAllNotifications,
  and MarkAllNotificationsRead (PocketBase requires double quotes for string values)
2026-04-13 21:18:49 +05:00
root
5b90667b4b fix: replace {#await} IIFE trick with $effect for streamed data on discover page
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 2m0s
Release / Docker (push) Successful in 7m4s
Release / Gitea Release (push) Successful in 28s
The {#await ... then} + {@const} IIFE pattern for assigning to $state
variables stopped working reliably in Svelte 5.53+. Replaced with a
proper $effect that awaits both streamed promises and assigns to state,
which correctly triggers reactivity.

Also: switch library page selection mode entry from long-press to a
'Select' button in the page header.
2026-04-13 21:14:14 +05:00
root
dec11f0c01 fix: hero carousel — horizontal book spine stack instead of vertical overlap
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 1m55s
Release / Docker (push) Successful in 7m23s
Release / Gitea Release (push) Successful in 53s
2026-04-13 21:08:51 +05:00
8 changed files with 96 additions and 120 deletions

View File

@@ -199,6 +199,7 @@ func run() error {
BookWriter: store, BookWriter: store,
AIJobStore: store, AIJobStore: store,
BookAdminStore: store, BookAdminStore: store,
NotificationStore: store,
Log: log, Log: log,
}, },
) )

View File

@@ -3,8 +3,6 @@ package backend
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"github.com/libnovel/backend/internal/storage"
) )
// handleDismissNotification handles DELETE /api/notifications/{id}. // handleDismissNotification handles DELETE /api/notifications/{id}.
@@ -14,12 +12,11 @@ func (s *Server) handleDismissNotification(w http.ResponseWriter, r *http.Reques
jsonError(w, http.StatusBadRequest, "notification id required") jsonError(w, http.StatusBadRequest, "notification id required")
return return
} }
store, ok := s.deps.Producer.(*storage.Store) if s.deps.NotificationStore == nil {
if !ok { jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
jsonError(w, http.StatusInternalServerError, "storage not available")
return return
} }
if err := store.DeleteNotification(r.Context(), id); err != nil { if err := s.deps.NotificationStore.DeleteNotification(r.Context(), id); err != nil {
jsonError(w, http.StatusInternalServerError, "dismiss notification: "+err.Error()) jsonError(w, http.StatusInternalServerError, "dismiss notification: "+err.Error())
return return
} }
@@ -33,12 +30,11 @@ func (s *Server) handleClearAllNotifications(w http.ResponseWriter, r *http.Requ
jsonError(w, http.StatusBadRequest, "user_id required") jsonError(w, http.StatusBadRequest, "user_id required")
return return
} }
store, ok := s.deps.Producer.(*storage.Store) if s.deps.NotificationStore == nil {
if !ok { jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
jsonError(w, http.StatusInternalServerError, "storage not available")
return return
} }
if err := store.ClearAllNotifications(r.Context(), userID); err != nil { if err := s.deps.NotificationStore.ClearAllNotifications(r.Context(), userID); err != nil {
jsonError(w, http.StatusInternalServerError, "clear notifications: "+err.Error()) jsonError(w, http.StatusInternalServerError, "clear notifications: "+err.Error())
return return
} }
@@ -52,12 +48,11 @@ func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.R
jsonError(w, http.StatusBadRequest, "user_id required") jsonError(w, http.StatusBadRequest, "user_id required")
return return
} }
store, ok := s.deps.Producer.(*storage.Store) if s.deps.NotificationStore == nil {
if !ok { jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
jsonError(w, http.StatusInternalServerError, "storage not available")
return return
} }
if err := store.MarkAllNotificationsRead(r.Context(), userID); err != nil { if err := s.deps.NotificationStore.MarkAllNotificationsRead(r.Context(), userID); err != nil {
jsonError(w, http.StatusInternalServerError, "mark all read: "+err.Error()) jsonError(w, http.StatusInternalServerError, "mark all read: "+err.Error())
return return
} }
@@ -80,13 +75,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
return return
} }
store, ok := s.deps.Producer.(*storage.Store) if s.deps.NotificationStore == nil {
if !ok { jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
jsonError(w, http.StatusInternalServerError, "storage not available")
return return
} }
items, err := store.ListNotifications(r.Context(), userID, 50) items, err := s.deps.NotificationStore.ListNotifications(r.Context(), userID, 50)
if err != nil { if err != nil {
jsonError(w, http.StatusInternalServerError, "list notifications: "+err.Error()) jsonError(w, http.StatusInternalServerError, "list notifications: "+err.Error())
return return
@@ -97,7 +91,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
for _, item := range items { for _, item := range items {
b, _ := json.Marshal(item) b, _ := json.Marshal(item)
var n notification var n notification
json.Unmarshal(b, &n) json.Unmarshal(b, &n) //nolint:errcheck
notifications = append(notifications, n) notifications = append(notifications, n)
} }
@@ -111,16 +105,15 @@ func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Reque
return return
} }
store, ok := s.deps.Producer.(*storage.Store) if s.deps.NotificationStore == nil {
if !ok { jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
jsonError(w, http.StatusInternalServerError, "storage not available")
return return
} }
if err := store.MarkNotificationRead(r.Context(), id); err != nil { if err := s.deps.NotificationStore.MarkNotificationRead(r.Context(), id); err != nil {
jsonError(w, http.StatusInternalServerError, "mark read: "+err.Error()) jsonError(w, http.StatusInternalServerError, "mark read: "+err.Error())
return return
} }
writeJSON(w, 0, map[string]any{"success": true}) writeJSON(w, 0, map[string]any{"success": true})
} }

View File

@@ -94,6 +94,10 @@ type Dependencies struct {
// BookAdminStore provides admin-only operations: archive, unarchive, hard-delete. // BookAdminStore provides admin-only operations: archive, unarchive, hard-delete.
// If nil, the admin book management endpoints return 503. // If nil, the admin book management endpoints return 503.
BookAdminStore bookstore.BookAdminStore BookAdminStore bookstore.BookAdminStore
// NotificationStore manages per-user in-app notifications.
// Always wired directly to *storage.Store (not the Asynq wrapper) so
// notification endpoints work regardless of whether Redis/Asynq is in use.
NotificationStore bookstore.NotificationStore
// Log is the structured logger. // Log is the structured logger.
Log *slog.Logger Log *slog.Logger
} }

View File

@@ -247,3 +247,14 @@ type ImportFileStore interface {
// GetImportChapters retrieves the pre-parsed chapters JSON. // GetImportChapters retrieves the pre-parsed chapters JSON.
GetImportChapters(ctx context.Context, key string) ([]byte, error) GetImportChapters(ctx context.Context, key string) ([]byte, error)
} }
// NotificationStore manages per-user in-app notifications.
// Always wired directly to the concrete *storage.Store so it works
// regardless of whether the Asynq task-queue wrapper is in use.
type NotificationStore interface {
ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error)
MarkNotificationRead(ctx context.Context, id string) error
MarkAllNotificationsRead(ctx context.Context, userID string) error
DeleteNotification(ctx context.Context, id string) error
ClearAllNotifications(ctx context.Context, userID string) error
}

View File

@@ -773,7 +773,7 @@ func (s *Store) CreateNotification(ctx context.Context, userID, title, message,
// ListNotifications returns notifications for a user. // ListNotifications returns notifications for a user.
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) { func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) {
filter := fmt.Sprintf("user_id='%s'", userID) filter := fmt.Sprintf(`user_id="%s"`, userID)
items, err := s.pb.listAll(ctx, "notifications", filter, "-created") items, err := s.pb.listAll(ctx, "notifications", filter, "-created")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -805,7 +805,7 @@ func (s *Store) DeleteNotification(ctx context.Context, id string) error {
// ClearAllNotifications deletes all notifications for a user. // ClearAllNotifications deletes all notifications for a user.
func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error { func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error {
filter := fmt.Sprintf("user_id='%s'", userID) filter := fmt.Sprintf(`user_id="%s"`, userID)
items, err := s.pb.listAll(ctx, "notifications", filter, "") items, err := s.pb.listAll(ctx, "notifications", filter, "")
if err != nil { if err != nil {
return fmt.Errorf("ClearAllNotifications list: %w", err) return fmt.Errorf("ClearAllNotifications list: %w", err)
@@ -823,7 +823,7 @@ func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error
// MarkAllNotificationsRead marks all notifications for a user as read. // MarkAllNotificationsRead marks all notifications for a user as read.
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error { func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
filter := fmt.Sprintf("user_id='%s'&&read=false", userID) filter := fmt.Sprintf(`user_id="%s"&&read=false`, userID)
items, err := s.pb.listAll(ctx, "notifications", filter, "") items, err := s.pb.listAll(ctx, "notifications", filter, "")
if err != nil { if err != nil {
return fmt.Errorf("MarkAllNotificationsRead list: %w", err) return fmt.Errorf("MarkAllNotificationsRead list: %w", err)

View File

@@ -136,40 +136,18 @@
<!-- ── Hero carousel ──────────────────────────────────────────────────────────── --> <!-- ── Hero carousel ──────────────────────────────────────────────────────────── -->
{#if heroBook} {#if heroBook}
{@const stackBook1 = heroBooks[(heroIndex + 1) % heroBooks.length]} {@const stackBooks = heroBooks.length > 1
{@const stackBook2 = heroBooks[(heroIndex + 2) % heroBooks.length]} ? Array.from({ length: Math.min(heroBooks.length - 1, 3) }, (_, i) =>
heroBooks[(heroIndex + 1 + i) % heroBooks.length])
: []}
<section class="mb-6"> <section class="mb-6">
<div class="relative"> <!-- Outer flex row: front card + queued book spines (sm+ only) -->
<!-- Stack layer 2 (furthest back) — only shown on sm+ when there are 3+ books --> <div class="relative flex items-stretch gap-0">
{#if heroBooks.length >= 3}
<div class="hidden sm:block absolute inset-0 rounded-xl overflow-hidden pointer-events-none origin-bottom
translate-y-[-6px] translate-x-[10px] scale-[0.94] opacity-40 z-0">
{#if stackBook2.book.cover}
<img src={stackBook2.book.cover} alt="" aria-hidden="true"
class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-2)"></div>
{/if}
</div>
{/if}
<!-- Stack layer 1 (one behind front) — only shown on sm+ when there are 2+ books -->
{#if heroBooks.length >= 2}
<div class="hidden sm:block absolute inset-0 rounded-xl overflow-hidden pointer-events-none origin-bottom
translate-y-[-3px] translate-x-[5px] scale-[0.97] opacity-60 z-[1]">
{#if stackBook1.book.cover}
<img src={stackBook1.book.cover} alt="" aria-hidden="true"
class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-2)"></div>
{/if}
</div>
{/if}
<!-- Front card — swipe to navigate --> <!-- Front card — swipe to navigate -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all z-[2]" class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all z-[2] flex-1 min-w-0"
ontouchstart={onSwipeStart} ontouchstart={onSwipeStart}
ontouchend={onSwipeEnd} ontouchend={onSwipeEnd}
> >
@@ -226,23 +204,43 @@
</div> </div>
</div> </div>
<!-- Dot indicators --> <!-- Queued book spines — visible sm+ only, peek to the right of the front card -->
{#if heroBooks.length > 1} {#each stackBooks as stackBook, i}
<div class="flex items-center justify-center gap-2 mt-2.5"> {@const opacity = i === 0 ? 'opacity-70' : 'opacity-40'}
{#each heroBooks as _, i} {@const width = i === 0 ? 'sm:w-10' : 'sm:w-7'}
<button <a
type="button" href="/books/{stackBook.book.slug}/chapters/{stackBook.chapter}"
onclick={() => heroDot(i)} class="hidden sm:block shrink-0 {width} rounded-r-xl overflow-hidden border border-l-0 border-(--color-border) {opacity} hover:opacity-90 transition-opacity"
aria-label="Go to book {i + 1}" aria-label={stackBook.book.title}
> tabindex="-1"
<span class="block rounded-full transition-all duration-300 {i === heroIndex >
? 'w-4 h-1.5 bg-(--color-brand)' {#if stackBook.book.cover}
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"></span> <img src={stackBook.book.cover} alt="" aria-hidden="true"
</button> class="w-full h-full object-cover object-left" loading="lazy" />
{/each} {:else}
</div> <div class="w-full h-full bg-(--color-surface-3)"></div>
{/if} {/if}
</a>
{/each}
</div> </div>
<!-- Dot indicators -->
{#if heroBooks.length > 1}
<div class="flex items-center justify-center gap-2 mt-2.5">
{#each heroBooks as _, i}
<button
type="button"
onclick={() => heroDot(i)}
aria-label="Go to book {i + 1}"
>
<span class="block rounded-full transition-all duration-300 {i === heroIndex
? 'w-4 h-1.5 bg-(--color-brand)'
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"></span>
</button>
{/each}
</div>
{/if}
</section> </section>
{/if} {/if}

View File

@@ -53,11 +53,6 @@
filteredBooks.length > 0 && filteredBooks.every((b) => selected.has(b.slug)) filteredBooks.length > 0 && filteredBooks.every((b) => selected.has(b.slug))
); );
function enterSelectMode(slug: string) {
selectMode = true;
selected = new Set([slug]);
}
function exitSelectMode() { function exitSelectMode() {
selectMode = false; selectMode = false;
selected = new Set(); selected = new Set();
@@ -81,43 +76,11 @@
} }
} }
// Long-press support (pointer events, works on desktop + mobile) // Card click: in selection mode, toggle selection; otherwise navigate normally
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let longPressFired = false;
function onPointerDown(slug: string) {
if (selectMode) return;
longPressFired = false;
longPressTimer = setTimeout(() => {
longPressFired = true;
enterSelectMode(slug);
}, 500);
}
function onPointerUp() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function onPointerCancel() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// Prevent navigation click if long-press just fired
function onCardClick(e: MouseEvent, slug: string) { function onCardClick(e: MouseEvent, slug: string) {
if (selectMode) { if (selectMode) {
e.preventDefault(); e.preventDefault();
toggleSelect(slug); toggleSelect(slug);
return;
}
if (longPressFired) {
e.preventDefault();
longPressFired = false;
} }
} }
@@ -175,6 +138,14 @@
> >
Cancel Cancel
</button> </button>
{:else if data.books?.length}
<button
type="button"
onclick={() => { selectMode = true; }}
class="text-sm text-(--color-muted) hover:text-(--color-text) transition-colors pt-1"
>
Select
</button>
{/if} {/if}
</div> </div>
@@ -228,9 +199,6 @@
<a <a
href="/books/{book.slug}" href="/books/{book.slug}"
onclick={(e) => onCardClick(e, book.slug)} onclick={(e) => onCardClick(e, book.slug)}
onpointerdown={() => onPointerDown(book.slug)}
onpointerup={onPointerUp}
onpointercancel={onPointerCancel}
draggable="false" draggable="false"
class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) border transition-colors select-none class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) border transition-colors select-none
{isSelected {isSelected

View File

@@ -60,10 +60,17 @@
try { return JSON.parse(genres) as string[]; } catch { return []; } try { return JSON.parse(genres) as string[]; } catch { return []; }
} }
// Resolved books from streamed promise (populated in {#await} block via binding trick) // Resolved books from streamed promises — populated via $effect once promises settle
let resolvedBooks = $state<Book[]>([]); let resolvedBooks = $state<Book[]>([]);
let resolvedVotedBooks = $state<VotedBook[]>([]); let resolvedVotedBooks = $state<VotedBook[]>([]);
$effect(() => {
Promise.all([data.streamed.books, data.streamed.votedBooks]).then(([books, vb]) => {
resolvedBooks = books as Book[];
resolvedVotedBooks = vb as VotedBook[];
});
});
let deck = $derived.by(() => { let deck = $derived.by(() => {
let books = resolvedBooks; let books = resolvedBooks;
if (prefs.onboarded && prefs.genres.length > 0) { if (prefs.onboarded && prefs.genres.length > 0) {
@@ -479,12 +486,6 @@
</div> </div>
{/if} {/if}
<!-- ── Await streamed data ─────────────────────────────────────────────────────── -->
{#await Promise.all([data.streamed.books, data.streamed.votedBooks]) then [books, vb]}
<!-- Silently populate reactive state once data resolves -->
{@const _ = (() => { resolvedBooks = books as Book[]; resolvedVotedBooks = vb as VotedBook[]; return ''; })()}
{/await}
<!-- ── Page layout ────────────────────────────────────────────────────────────── --> <!-- ── Page layout ────────────────────────────────────────────────────────────── -->
<div class="select-none -mx-4 -my-8 lg:min-h-[calc(100svh-3.5rem)] <div class="select-none -mx-4 -my-8 lg:min-h-[calc(100svh-3.5rem)]
lg:grid lg:grid-cols-[1fr_380px] xl:grid-cols-[1fr_420px]"> lg:grid lg:grid-cols-[1fr_380px] xl:grid-cols-[1fr_420px]">