Compare commits
3 Commits
v2.6.91
...
v3.0.20260
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7009b24568 | ||
|
|
5b90667b4b | ||
|
|
dec11f0c01 |
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
Reference in New Issue
Block a user