Yuyuqt
add: blazor web assembly.
4433399
Raw
History Blame Contribute Delete
40.6 kB
@page "/Books"
@using BlazorFrontend.Services
@using LibraryManagement.Shared.Models
@using Microsoft.AspNetCore.Authorization
@inject LibraryApiClient ApiClient
@inject WishlistService WishlistService
@inject IJSRuntime JS
@inject NavigationManager Navigation
<PageTitle>Books Catalog</PageTitle>
@if (_viewMode == ViewMode.List)
{
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8 animate-in fade-in slide-in-from-top-4 duration-500">
<div>
<h1 class="text-3xl font-bold tracking-tight text-mystic-900">Books Catalog</h1>
<p class="mt-2 text-mystic-500">
Explore and discover your next great read.
<span class="ml-2 text-xs text-mystic-400">(@FilteredBooks.Count() total)</span>
</p>
</div>
<AuthorizeView Roles="Librarian">
<div class="flex items-center gap-3">
<button @onclick="ShowCreate" class="btn-primary">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
Add New Book
</button>
</div>
</AuthorizeView>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6 animate-in fade-in duration-700">
<div class="md:col-span-3">
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-mystic-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
</div>
<input type="text" @bind="_searchQuery" @bind:event="oninput"
class="block w-full pl-10 pr-3 py-2 border border-mystic-200 rounded-lg bg-white placeholder-mystic-400 focus:outline-none focus:ring-2 focus:ring-mystic-900 focus:border-transparent sm:text-sm shadow-sm"
placeholder="Search by title, author or ISBN...">
</div>
</div>
</div>
<!-- Quick Filters -->
<div class="flex flex-wrap items-center gap-3 mb-4 animate-in fade-in duration-700">
<button @onclick='() => SetQuickFilter("All")'
class='inline-flex items-center px-5 py-2.5 rounded-full text-sm font-bold border transition-all @(_activeQuickFilter == "All" ? "bg-mystic-900 text-white border-mystic-900 shadow-md" : "bg-white text-mystic-600 border-mystic-200 hover:border-mystic-400 hover:text-mystic-900")'>
All Books
</button>
<button @onclick='() => SetQuickFilter("New Arrivals")'
class='inline-flex items-center px-5 py-2.5 rounded-full text-sm font-bold border transition-all @(_activeQuickFilter == "New Arrivals" ? "bg-mystic-900 text-white border-mystic-900 shadow-md" : "bg-white text-mystic-600 border-mystic-200 hover:border-mystic-400 hover:text-mystic-900")'>
New Arrivals
</button>
<button @onclick='() => SetQuickFilter("Trending")'
class='inline-flex items-center px-5 py-2.5 rounded-full text-sm font-bold border transition-all @(_activeQuickFilter == "Trending" ? "bg-mystic-900 text-white border-mystic-900 shadow-md" : "bg-white text-mystic-600 border-mystic-200 hover:border-mystic-400 hover:text-mystic-900")'>
Trending
</button>
<button @onclick='() => SetQuickFilter("Recommended")'
class='inline-flex items-center px-5 py-2.5 rounded-full text-sm font-bold border transition-all @(_activeQuickFilter == "Recommended" ? "bg-mystic-900 text-white border-mystic-900 shadow-md" : "bg-white text-mystic-600 border-mystic-200 hover:border-mystic-400 hover:text-mystic-900")'>
Recommended for You
</button>
</div>
@if (_categories.Any())
{
<div class="mb-8 animate-in fade-in duration-700">
<button @onclick="ToggleCategoryDropdown" class="group inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-mystic-400 hover:text-mystic-900 transition-colors">
<div class="w-6 h-6 rounded-lg bg-mystic-50 flex items-center justify-center group-hover:bg-mystic-100 transition-colors">
<svg class="w-3.5 h-3.5 transition-transform duration-300 @(_isCategoryDropdownOpen ? "rotate-90" : "")" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M9 5l7 7-7 7"></path></svg>
</div>
<span>Browse by Category</span>
@if (_selectedCategoryId != null)
{
<span class="ml-2 px-2 py-0.5 bg-mystic-900 text-white text-[10px] rounded-full">@SelectedCategoryName</span>
}
</button>
@if (_isCategoryDropdownOpen)
{
<div class="flex flex-wrap items-center gap-2 mt-4 animate-in slide-in-from-top-2 duration-300">
<button @onclick="() => FilterByCategory(null)"
class='inline-flex items-center px-4 py-1.5 rounded-full text-[11px] font-bold border transition-all @(_selectedCategoryId == null ? "bg-mystic-900 text-white border-mystic-900 shadow-sm" : "bg-white text-mystic-600 border-mystic-200 hover:border-mystic-400 hover:text-mystic-900")'>
All
</button>
@foreach (var cat in _categories)
{
<button @onclick="() => FilterByCategory(cat.Id)"
class='inline-flex items-center px-4 py-1.5 rounded-full text-[11px] font-bold border transition-all @(_selectedCategoryId == cat.Id ? "bg-mystic-900 text-white border-mystic-900 shadow-sm" : "bg-white text-mystic-600 border-mystic-200 hover:border-mystic-400 hover:text-mystic-900")'>
@cat.Name
</button>
}
</div>
}
</div>
}
@if (_isLoading)
{
<div class="py-20 flex justify-center">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-mystic-900"></div>
</div>
}
else if (!FilteredBooks.Any())
{
<div class="py-20 text-center bg-mystic-50 rounded-3xl border-2 border-dashed border-mystic-200 animate-in fade-in duration-500">
<svg class="w-16 h-16 mx-auto text-mystic-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
<p class="text-mystic-500 font-medium">No books found matching your criteria.</p>
</div>
}
else
{
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-4 gap-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
@foreach (var book in PaginatedBooks)
{
<div class="group card hover:shadow-lg transition-all duration-300 flex flex-col h-full">
<div @onclick="() => ShowDetails(book.Id)" class="relative aspect-[2/3] w-full bg-mystic-100 flex items-center justify-center text-mystic-300 overflow-hidden text-center cursor-pointer group-hover:scale-105 transition-transform duration-500">
@if (!string.IsNullOrWhiteSpace(book.CoverUrl))
{
<img src="@book.CoverUrl" alt="@book.Title" class="absolute inset-0 w-full h-full object-cover" />
}
else
{
<div class="flex flex-col items-center gap-4 p-4">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
<span class="text-[10px] font-bold text-mystic-400">@book.Isbn</span>
</div>
}
@if (book.AvailableCopies <= 0)
{
<div class="absolute inset-0 bg-mystic-900/60 backdrop-blur-[2px] flex items-center justify-center">
<span class="bg-white text-mystic-900 text-[10px] font-bold uppercase tracking-wider px-2 py-1 rounded">Out of Stock</span>
</div>
}
</div>
<div class="p-4 flex-grow flex flex-col">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<h3 @onclick="() => ShowDetails(book.Id)" class="text-sm font-bold text-mystic-900 truncate group-hover:text-mystic-600 transition-colors cursor-pointer">
@book.Title
</h3>
<p class="text-xs text-mystic-500 mt-0.5 truncate">@book.Author</p>
</div>
</div>
@if (book.Categories.Any())
{
<div class="mt-2 flex flex-wrap gap-1">
@foreach (var cat in book.Categories)
{
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2 py-0.5 text-[9px] font-semibold text-amber-700">
@cat.Name
</span>
}
</div>
}
<div class="mt-3 flex items-center justify-between">
<span class="inline-flex items-center rounded-full bg-mystic-100 px-2 py-0.5 text-[10px] font-medium text-mystic-600">@book.Status</span>
<span class="text-[10px] font-medium @(book.AvailableCopies > 0 ? "text-emerald-600" : "text-rose-600")">
@book.AvailableCopies / @book.TotalCopies
</span>
</div>
<div class="mt-auto pt-4 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button @onclick="() => ShowDetails(book.Id)" class="flex-1 btn-secondary py-1 text-[11px]">Details</button>
<AuthorizeView Roles="Member">
<div class="flex gap-1">
<button @onclick="() => ToggleFavourite(book)"
class='p-1.5 rounded-md border transition-all @(IsFavourite(book.Id) ? "border-rose-200 bg-rose-50 text-rose-500" : "border-mystic-200 text-mystic-400 hover:bg-rose-50 hover:text-rose-500")'
title='@(IsFavourite(book.Id) ? "Remove from Favourites" : "Add to Favourites")'>
<svg class="w-3.5 h-3.5" fill='@(IsFavourite(book.Id) ? "currentColor" : "none")' stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001z"></path>
</svg>
</button>
@if (book.AvailableCopies == 0)
{
<button @onclick="() => ToggleWishlist(book)"
class='p-1.5 rounded-md border transition-all @(IsInWishlist(book.Id) ? "border-amber-200 bg-amber-50 text-amber-500" : "border-mystic-200 text-mystic-400 hover:bg-amber-50 hover:text-amber-500")'
title='@(IsInWishlist(book.Id) ? "Remove from Wishlist" : "Add to Wishlist")'>
<svg class="w-3.5 h-3.5" fill='@(IsInWishlist(book.Id) ? "currentColor" : "none")' stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path>
</svg>
</button>
}
</div>
</AuthorizeView>
<AuthorizeView Roles="Librarian">
<button @onclick="() => ShowEdit(book.Id)" class="p-1.5 rounded-md border border-mystic-200 hover:bg-mystic-50 text-mystic-600">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
</button>
</AuthorizeView>
</div>
</div>
</div>
}
</div>
@if (TotalPages > 1)
{
<div class="mt-12 flex items-center justify-center gap-2">
<button disabled="@(_currentPage == 1)" @onclick="() => _currentPage--"
class="p-2 rounded-lg border border-mystic-200 text-mystic-600 hover:bg-mystic-50 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
</button>
@for (int i = 1; i <= TotalPages; i++)
{
var pageNumber = i;
<button @onclick="() => _currentPage = pageNumber"
class='w-10 h-10 rounded-lg border text-sm font-bold transition-all @(_currentPage == pageNumber ? "bg-mystic-900 border-mystic-900 text-white shadow-md" : "bg-white border-mystic-200 text-mystic-600 hover:border-mystic-400 hover:text-mystic-900")'>
@pageNumber
</button>
}
<button disabled="@(_currentPage == TotalPages)" @onclick="() => _currentPage++"
class="p-2 rounded-lg border border-mystic-200 text-mystic-600 hover:bg-mystic-50 disabled:opacity-30 disabled:cursor-not-allowed transition-all">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</button>
</div>
}
}
}
else if (_viewMode == ViewMode.Details && _selectedBook != null)
{
<div class="mb-8 animate-in fade-in slide-in-from-left-4 duration-500">
<button @onclick="ShowList" class="inline-flex items-center text-sm font-medium text-mystic-500 hover:text-mystic-900 mb-6 group">
<svg class="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>
Back to Catalog
</button>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
<div class="lg:col-span-1">
<div class="aspect-[2/3] w-full rounded-2xl bg-mystic-100 border border-mystic-200 shadow-sm flex items-center justify-center text-mystic-300 overflow-hidden">
@if (!string.IsNullOrWhiteSpace(_selectedBook.CoverUrl))
{
<img src="@_selectedBook.CoverUrl" alt="@_selectedBook.Title" class="w-full h-full object-cover" />
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>
}
</div>
</div>
<div class="lg:col-span-2 space-y-8">
<div>
<span class="inline-flex items-center rounded-full bg-mystic-100 px-3 py-1 text-xs font-semibold text-mystic-600 mb-4 tracking-wide uppercase">@_selectedBook.Status</span>
<h1 class="text-4xl font-extrabold text-mystic-900 tracking-tight">@_selectedBook.Title</h1>
<p class="text-xl text-mystic-500 mt-2 font-medium">by @_selectedBook.Author</p>
</div>
<div class="prose prose-mystic max-w-none">
<h3 class="text-lg font-bold text-mystic-900 border-b border-mystic-100 pb-2 mb-4 uppercase tracking-wider text-sm">Description</h3>
<p class="text-mystic-600 leading-relaxed">
@(string.IsNullOrEmpty(_selectedBook.Description) ? "No description available for this book." : _selectedBook.Description)
</p>
</div>
<div class="grid grid-cols-2 gap-8 border-y border-mystic-100 py-6">
<div>
<h4 class="text-[10px] font-bold text-mystic-400 uppercase tracking-widest mb-1">ISBN</h4>
<p class="text-sm font-semibold text-mystic-900">@_selectedBook.Isbn</p>
</div>
<div>
<h4 class="text-[10px] font-bold text-mystic-400 uppercase tracking-widest mb-1">Availability</h4>
<p class="text-sm font-semibold @(_selectedBook.AvailableCopies > 0 ? "text-emerald-600" : "text-rose-600")">
@(_selectedBook.AvailableCopies > 0 ? $"{_selectedBook.AvailableCopies} / {_selectedBook.TotalCopies} units in stock" : "Currently occupied")
</p>
</div>
</div>
<div class="flex items-center gap-4 pt-4">
<AuthorizeView Roles="Member">
<div class="flex items-center gap-3">
@if (_selectedBook.AvailableCopies > 0)
{
<button @onclick="() => BorrowAsync(_selectedBook.Id)" class="btn-primary py-3 px-8 text-base shadow-lg shadow-mystic-200">Borrow This Book</button>
}
else
{
<div class="flex flex-col gap-2">
<button disabled class="btn-primary py-3 px-8 text-base opacity-50 cursor-not-allowed">Out of Stock</button>
<button @onclick="() => ToggleWishlist(_selectedBook)"
class='text-xs font-bold flex items-center gap-1.5 transition-colors @(IsInWishlist(_selectedBook.Id) ? "text-amber-600 underline" : "text-mystic-400 hover:text-amber-600 hover:underline")'>
<svg class="w-4 h-4" fill='@(IsInWishlist(_selectedBook.Id) ? "currentColor" : "none")' stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path>
</svg>
@(IsInWishlist(_selectedBook.Id) ? "In Wishlist (Notifying)" : "Add to Wishlist for Notification")
</button>
</div>
}
<button @onclick="() => ToggleFavourite(_selectedBook)"
class='p-3 rounded-xl border transition-all shadow-sm active:scale-95 @(IsFavourite(_selectedBook.Id) ? "border-rose-200 bg-rose-50 text-rose-500" : "border-mystic-200 text-mystic-400 hover:bg-rose-50 hover:text-rose-500")'
title='@(IsFavourite(_selectedBook.Id) ? "Remove from Favourites" : "Add to Favourites")'>
<svg class="w-6 h-6" fill='@(IsFavourite(_selectedBook.Id) ? "currentColor" : "none")' stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001z"></path>
</svg>
</button>
</div>
</AuthorizeView>
<AuthorizeView Roles="Librarian">
<button @onclick="() => ShowEdit(_selectedBook.Id)" class="btn-secondary py-3 px-8 text-base">Edit Details</button>
<button @onclick="() => DeleteAsync(_selectedBook.Id)" class="p-3 text-rose-600 hover:text-rose-700 hover:bg-rose-50 rounded-xl transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</button>
</AuthorizeView>
<AuthorizeView>
<NotAuthorized>
<a href="/login" class="btn-primary py-3 px-8 text-base">Sign In to Borrow</a>
</NotAuthorized>
</AuthorizeView>
</div>
</div>
</div>
</div>
}
else if (_viewMode == ViewMode.Create || _viewMode == ViewMode.Edit)
{
<div class="mb-8 animate-in fade-in slide-in-from-right-4 duration-500">
<button @onclick="ShowList" class="inline-flex items-center text-sm font-medium text-mystic-500 hover:text-mystic-900 mb-6 group">
<svg class="w-4 h-4 mr-2 transition-transform group-hover:-translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>
Back to Catalog
</button>
<div class="max-w-3xl mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold tracking-tight text-mystic-900">@(_viewMode == ViewMode.Create ? "Add New Book" : "Edit Book")</h1>
<p class="mt-2 text-mystic-500">Fill in the details below to @(_viewMode == ViewMode.Create ? "add a new book to" : "update the") library catalog.</p>
</div>
<div class="card p-6 sm:p-8">
<EditForm Model="@_formModel" OnValidSubmit="HandleSubmit" class="space-y-6">
<DataAnnotationsValidator />
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-mystic-700">Book Title <span class="text-rose-500">*</span></label>
<InputText @bind-Value="_formModel.Title" class="mt-1 block w-full rounded-md border-mystic-300 shadow-sm focus:border-mystic-900 focus:ring-mystic-900 sm:text-sm px-3 py-2 border bg-white" placeholder="e.g. The Great Gatsby" required />
</div>
<div>
<label class="block text-sm font-medium text-mystic-700">Author <span class="text-rose-500">*</span></label>
<InputText @bind-Value="_formModel.Author" class="mt-1 block w-full rounded-md border-mystic-300 shadow-sm focus:border-mystic-900 focus:ring-mystic-900 sm:text-sm px-3 py-2 border bg-white" placeholder="e.g. F. Scott Fitzgerald" required />
</div>
<div>
<label class="block text-sm font-medium text-mystic-700">ISBN <span class="text-rose-500">*</span></label>
<InputText @bind-Value="_formModel.Isbn" class="mt-1 block w-full rounded-md border-mystic-300 shadow-sm focus:border-mystic-900 focus:ring-mystic-900 sm:text-sm px-3 py-2 border bg-white" placeholder="e.g. 978-0743273565" required />
</div>
<div>
<label class="block text-sm font-medium text-mystic-700">Total Copies <span class="text-rose-500">*</span></label>
<InputNumber @bind-Value="_formModel.TotalCopies" class="mt-1 block w-full rounded-md border-mystic-300 shadow-sm focus:border-mystic-900 focus:ring-mystic-900 sm:text-sm px-3 py-2 border bg-white" placeholder="1" required />
</div>
<div>
<label class="block text-sm font-medium text-mystic-700">Cover Image URL</label>
<InputText @bind-Value="_formModel.CoverUrl" class="mt-1 block w-full rounded-md border-mystic-300 shadow-sm focus:border-mystic-900 focus:ring-mystic-900 sm:text-sm px-3 py-2 border bg-white" placeholder="https://example.com/cover.jpg" />
</div>
<div class="hidden sm:block"></div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-mystic-700">Description</label>
<InputTextArea @bind-Value="_formModel.Description" rows="4" class="mt-1 block w-full rounded-md border-mystic-300 shadow-sm focus:border-mystic-900 focus:ring-mystic-900 sm:text-sm px-3 py-2 border bg-white" placeholder="Brief summary of the book..."></InputTextArea>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-mystic-700 mb-2">Categories</label>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
@foreach (var cat in _categories)
{
<label class="flex items-center gap-2 px-3 py-2 rounded-md border border-mystic-200 bg-mystic-50 hover:bg-mystic-100 cursor-pointer text-sm text-mystic-700 transition-colors">
<input type="checkbox" checked="@(_formModel.CategoryIds.Contains(cat.Id))"
@onchange='(e) => ToggleCategory(cat.Id, (bool)e.Value!)'
class="h-4 w-4 rounded border-mystic-300 text-mystic-900 focus:ring-mystic-900" />
@cat.Name
</label>
}
</div>
</div>
</div>
<div class="pt-5 border-t border-mystic-100 flex justify-end gap-3">
<button type="button" @onclick="ShowList" class="btn-secondary">Cancel</button>
<button type="submit" class="btn-primary" disabled="@_isSubmitting">
@(_isSubmitting ? "Saving..." : (_viewMode == ViewMode.Create ? "Add Book" : "Update Book"))
</button>
</div>
</EditForm>
</div>
</div>
</div>
}
@code {
private enum ViewMode { List, Details, Create, Edit }
private ViewMode _viewMode = ViewMode.List;
private bool _isLoading = true;
private bool _isSubmitting = false;
private string _searchQuery = "";
private int? _selectedCategoryId;
private string _activeQuickFilter = "All";
private bool _isCategoryDropdownOpen = false;
// Pagination
private int _currentPage = 1;
private int _pageSize = 12;
private IEnumerable<BookDto> _books = Enumerable.Empty<BookDto>();
private IEnumerable<CategoryDto> _categories = Enumerable.Empty<CategoryDto>();
private IEnumerable<BorrowingDto> _myBorrowings = Enumerable.Empty<BorrowingDto>();
private IEnumerable<BorrowingDto> _allBorrowings = Enumerable.Empty<BorrowingDto>();
private BookDto? _selectedBook;
// Form Model
private BookFormModel _formModel = new();
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_isLoading = true;
try
{
_books = await ApiClient.GetBooksAsync();
_categories = await ApiClient.GetCategoriesAsync();
// Fetch borrowings for Trending and Recommended
_myBorrowings = await ApiClient.GetMyBorrowingsAsync();
try {
_allBorrowings = await ApiClient.GetAllBorrowingsAsync();
} catch { }
_currentPage = 1;
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
finally
{
_isLoading = false;
}
}
private IEnumerable<BookDto> FilteredBooks
{
get
{
var books = _books;
// Apply Search
if (!string.IsNullOrWhiteSpace(_searchQuery))
{
books = books.Where(b => b.Title.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) ||
b.Author.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) ||
b.Isbn.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase));
}
// Apply Category Filter
if (_selectedCategoryId != null)
{
books = books.Where(b => b.Categories.Any(c => c.Id == _selectedCategoryId));
}
// Apply Quick Filter
switch (_activeQuickFilter)
{
case "New Arrivals":
books = books.Where(b => b.CreatedAt >= DateTime.UtcNow.AddDays(-7));
break;
case "Trending":
var trendingIds = _allBorrowings
.Where(br => br.BorrowDate >= DateTime.UtcNow.AddDays(-7))
.GroupBy(br => br.BookId)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.Take(12)
.ToList();
if (trendingIds.Any())
books = books.Where(b => trendingIds.Contains(b.Id));
else
books = Enumerable.Empty<BookDto>(); // Or show nothing if no data
break;
case "Recommended":
var myCategoryIds = _myBorrowings.SelectMany(br => _books.FirstOrDefault(b => b.Id == br.BookId)?.Categories.Select(c => c.Id) ?? Enumerable.Empty<int>())
.Concat(_favourites.SelectMany(b => b.Categories.Select(c => c.Id)))
.Concat(_wishlist.SelectMany(b => b.Categories.Select(c => c.Id)))
.Distinct()
.ToList();
if (myCategoryIds.Any())
books = books.Where(b => b.Categories.Any(c => myCategoryIds.Contains(c.Id))).Take(8);
break;
}
return books;
}
}
private IEnumerable<BookDto> PaginatedBooks => FilteredBooks
.Skip((_currentPage - 1) * _pageSize)
.Take(_pageSize);
private int TotalPages => (int)Math.Ceiling(FilteredBooks.Count() / (double)_pageSize);
private string? SelectedCategoryName => _categories.FirstOrDefault(c => c.Id == _selectedCategoryId)?.Name;
private void ToggleCategoryDropdown() => _isCategoryDropdownOpen = !_isCategoryDropdownOpen;
private void SetQuickFilter(string filter)
{
_activeQuickFilter = filter;
_selectedCategoryId = null; // Clear category when switching quick filters
_currentPage = 1;
_isCategoryDropdownOpen = false;
}
private void FilterByCategory(int? id)
{
_selectedCategoryId = id;
_currentPage = 1;
}
private void ShowList()
{
_viewMode = ViewMode.List;
_selectedBook = null;
}
private async Task ShowDetails(int id)
{
_isLoading = true;
_selectedBook = await ApiClient.GetBookAsync(id);
_viewMode = ViewMode.Details;
_isLoading = false;
}
private void ShowCreate()
{
_formModel = new BookFormModel();
_viewMode = ViewMode.Create;
}
private async Task ShowEdit(int id)
{
_isLoading = true;
var book = await ApiClient.GetBookAsync(id);
if (book != null)
{
_selectedBook = book;
_formModel = new BookFormModel
{
Id = book.Id,
Title = book.Title,
Author = book.Author,
Isbn = book.Isbn,
Description = book.Description,
TotalCopies = book.TotalCopies,
CoverUrl = book.CoverUrl,
CategoryIds = book.Categories.Select(c => c.Id).ToList()
};
_viewMode = ViewMode.Edit;
}
_isLoading = false;
}
private void ToggleCategory(int id, bool isChecked)
{
if (isChecked && !_formModel.CategoryIds.Contains(id))
_formModel.CategoryIds.Add(id);
else if (!isChecked)
_formModel.CategoryIds.Remove(id);
}
private List<BookDto> _wishlist = new();
private List<BookDto> _favourites = new();
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await LoadCollectionsAsync();
StateHasChanged();
}
}
private async Task LoadCollectionsAsync()
{
_wishlist = await WishlistService.GetWishlistAsync();
_favourites = await WishlistService.GetFavouritesAsync();
}
private bool IsFavourite(int id) => _favourites.Any(b => b.Id == id);
private bool IsInWishlist(int id) => _wishlist.Any(b => b.Id == id);
private async Task ToggleFavourite(BookDto book)
{
if (IsFavourite(book.Id))
{
await WishlistService.RemoveFromFavouriteAsync(book.Id);
await JS.InvokeVoidAsync("showToast", $"Removed \"{book.Title}\" from favourites", "success");
}
else
{
await WishlistService.AddToFavouritesAsync(book);
await JS.InvokeVoidAsync("showToast", $"Added \"{book.Title}\" to favourites!", "success");
}
await LoadCollectionsAsync();
}
private async Task ToggleWishlist(BookDto book)
{
if (IsInWishlist(book.Id))
{
await WishlistService.RemoveFromWishlistAsync(book.Id);
await JS.InvokeVoidAsync("showToast", $"Removed \"{book.Title}\" from wishlist", "success");
}
else
{
await WishlistService.AddToWishlistAsync(book);
await JS.InvokeVoidAsync("showToast", $"Added \"{book.Title}\" to wishlist!", "success");
}
await LoadCollectionsAsync();
}
private async Task HandleSubmit()
{
_isSubmitting = true;
try
{
if (_viewMode == ViewMode.Create)
{
var request = new BookCreateRequest
{
Title = _formModel.Title,
Author = _formModel.Author,
Isbn = _formModel.Isbn,
Description = _formModel.Description,
TotalCopies = _formModel.TotalCopies,
CoverUrl = _formModel.CoverUrl,
CategoryIds = _formModel.CategoryIds
};
var result = await ApiClient.CreateBookAsync(request);
if (result != null)
{
await JS.InvokeVoidAsync("showToast", "Book added successfully!", "success");
await LoadDataAsync();
ShowList();
}
}
else
{
var request = new BookUpdateRequest
{
Title = _formModel.Title,
Author = _formModel.Author,
Description = _formModel.Description,
TotalCopies = _formModel.TotalCopies,
CoverUrl = _formModel.CoverUrl,
CategoryIds = _formModel.CategoryIds
};
var result = await ApiClient.UpdateBookAsync(_formModel.Id, request);
if (result != null)
{
await JS.InvokeVoidAsync("showToast", "Book updated successfully!", "success");
await LoadDataAsync();
ShowList();
}
}
}
catch (Exception ex)
{
await JS.InvokeVoidAsync("showToast", $"Error: {ex.Message}", "error");
}
finally
{
_isSubmitting = false;
}
}
private async Task DeleteAsync(int id)
{
bool confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to remove this book from the catalog?");
if (!confirmed) return;
var success = await ApiClient.DeleteBookAsync(id);
if (success)
{
await JS.InvokeVoidAsync("showToast", "Book deleted successfully", "success");
await LoadDataAsync();
ShowList();
}
}
private async Task BorrowAsync(int id)
{
var result = await ApiClient.BorrowBookAsync(new BorrowRequest { BookId = id });
if (result != null)
{
await JS.InvokeVoidAsync("showToast", "Book borrowed successfully! Check 'My Loans' for details.", "success");
await LoadDataAsync();
await ShowDetails(id);
}
else
{
await JS.InvokeVoidAsync("showToast", "Failed to borrow book. Check your loan limit.", "error");
}
}
private class BookFormModel
{
public int Id { get; set; }
public string Title { get; set; } = "";
public string Author { get; set; } = "";
public string Isbn { get; set; } = "";
public string? Description { get; set; }
public int TotalCopies { get; set; } = 1;
public string? CoverUrl { get; set; }
public List<int> CategoryIds { get; set; } = new();
}
}