Updated design

This commit is contained in:
2026-07-19 09:54:35 +02:00
parent e3a735acda
commit 804e7f90d1
36 changed files with 403 additions and 328 deletions
@@ -0,0 +1,165 @@
@namespace MidrandBooks.Components.Layout
@using Microsoft.AspNetCore.Components
@inject IJSRuntime JSRuntime
@if (ShowBanner || ShowPrivacyModal)
{
@if (ShowBanner)
{
<div id="cookie-acceptance-banner"
class="fixed bottom-4 left-4 z-40 max-w-sm w-[calc(100vw-2rem)] sm:w-[380px] bg-white/90 backdrop-blur-xl border border-slate-200/80 rounded-2xl p-4 shadow-xl flex flex-col gap-3 animate-in slide-in-from-bottom-8 fade-in duration-500 hover:shadow-slate-200/50 transition-all">
<!-- Accent Line -->
<div class="absolute top-0 left-6 right-6 h-0.5 bg-gradient-to-r from-amber-500/0 via-amber-500/60 to-amber-500/0"></div>
<div class="flex items-start gap-3">
<!-- Visual Icon -->
<div class="h-9 w-9 rounded-xl bg-amber-50 border border-amber-200/60 text-amber-600 flex items-center justify-center flex-shrink-0">
<svg class="h-5 w-5 animate-pulse" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5" />
<path d="M8.5 8.5v.01" />
<path d="M16 15.5v.01" />
<path d="M12 12v.01" />
<path d="M11 16v.01" />
<path d="M15 11v.01" />
</svg>
</div>
<div class="space-y-1">
<h4 class="text-xs font-sans font-bold text-slate-800 tracking-tight">
Cookie &amp; POPIA Protocol
</h4>
<p class="text-[11px] text-slate-600 leading-relaxed font-medium">
We utilize essential identifiers to facilitate secure electronic catalog licensing, sandboxed media playbacks, and cryptographic PayFast gateway handshake handoffs under <strong class="text-amber-600 font-bold">Lite Charms (Pty) Ltd</strong>.
</p>
</div>
<button @onclick="DismissBanner"
class="text-slate-400 hover:text-slate-600 transition-colors cursor-pointer p-0.5"
aria-label="Dismiss banner">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Action Links & Buttons -->
<div class="flex items-center justify-between mt-1 pt-2.5 border-t border-slate-100">
<button @onclick="OpenPrivacyModal"
class="text-[11px] font-semibold text-amber-600 hover:text-amber-700 underline cursor-pointer flex items-center gap-1 focus:outline-none">
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Privacy Policy
</button>
<div class="flex items-center gap-2">
<button @onclick="AcceptCookies"
class="bg-slate-900 hover:bg-slate-800 text-white rounded-xl py-1.5 px-3.5 text-[10px] font-mono font-bold uppercase tracking-wider transition-all cursor-pointer shadow shadow-slate-900/10 hover:scale-[1.02] active:scale-[0.98]">
Accept All
</button>
</div>
</div>
</div>
}
@if (ShowPrivacyModal)
{
<div id="cookie-privacy-modal-overlay"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md overflow-y-auto">
<div id="cookie-privacy-modal-content"
class="relative w-full max-w-5xl bg-slate-950 border border-white/10 rounded-3xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200">
<!-- Upper Highlight Bar -->
<div class="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-amber-500 via-amber-400 to-amber-600" />
<!-- Modal Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-slate-900/40 relative z-10 flex-shrink-0">
<div class="flex items-center gap-2 text-white">
<svg class="h-5 w-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span class="font-mono text-xs font-bold uppercase tracking-wider text-slate-300">
Secure Legal Document Portal
</span>
</div>
<button @onclick="ClosePrivacyModal"
class="p-1.5 rounded-xl border border-white/10 bg-white/5 text-slate-400 hover:text-white shadow-sm hover:scale-105 transition-all cursor-pointer"
aria-label="Close modal">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Scrollable Body -->
<div class="flex-1 overflow-y-auto bg-slate-950/50 p-4 sm:p-6 md:p-8">
<div class="max-w-4xl mx-auto -my-10">
<MidrandBooks.Components.Privacy />
</div>
</div>
<!-- Modal Footer -->
<div class="px-6 py-4 border-t border-white/10 bg-slate-900/40 flex items-center justify-end flex-shrink-0">
<button @onclick="ClosePrivacyModal"
class="bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 rounded-xl py-2 px-5 text-xs font-mono font-bold uppercase tracking-wider transition-all cursor-pointer">
Close Document
</button>
</div>
</div>
</div>
}
}
@code {
private bool ShowBanner { get; set; } = false;
private bool ShowPrivacyModal { get; set; } = false;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
try
{
var isAccepted = await JSRuntime.InvokeAsync<string>("localStorage.getItem", "midrandbooks_cookie_accepted");
if (isAccepted != "true")
{
await Task.Delay(1500);
ShowBanner = true;
StateHasChanged();
}
}
catch
{
// Fail gracefully if localStorage is blocked
}
}
}
private async Task AcceptCookies()
{
try
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", "midrandbooks_cookie_accepted", "true");
}
catch
{
// Fail gracefully
}
ShowBanner = false;
}
private void DismissBanner()
{
ShowBanner = false;
}
private void OpenPrivacyModal()
{
ShowPrivacyModal = true;
}
private void ClosePrivacyModal()
{
ShowPrivacyModal = false;
}
}
@@ -0,0 +1,437 @@
@inherits LayoutComponentBase
@namespace MidrandBooks.Components.Layout
@using Microsoft.AspNetCore.Components
@using MidrandBooks.Components
@using MidrandBooks.Models
<div class="relative min-h-screen flex flex-col justify-between selection:bg-violet-500/10 selection:text-violet-700">
<!-- Dynamic Ambient Motion Background -->
<AnimatedBackground />
<!-- Top Navigation Header -->
<Navbar
ActiveTab="@ActiveTab"
OnActiveTabChanged="HandleActiveTabChanged"
OnOpenCart="HandleOpenCart"
OnSelectBook="HandleSelectBook"
Books="CatalogBooks"
CartCount="CartItems.Count"
IsOfflineMode="IsOfflineMode"
OnOfflineModeToggle="HandleOfflineToggle"
/>
<!-- Main Central Work Space -->
<main class="flex-1 w-full max-w-7xl mx-auto px-4 sm:px-6 relative z-10 py-6">
@if (ActiveTab == "catalog")
{
<!-- Featured Slide Carousel Section -->
<FeaturedCarousel
FeaturedBooks="FeaturedBooks"
Purchases="PurchasedRecords"
Cart="CartItems"
OnSelectBook="HandleSelectBook"
OnReadEbook="HandleReadEbook"
OnStreamAudiobook="HandlePlayAudiobook"
OnAddToCart="HandleAddToCart"
/>
<!-- Main Interactive Catalog Grid -->
<BookGrid
Books="CatalogBooks"
Categories="Categories"
OnSelectBook="HandleSelectBook"
/>
}
else if (ActiveTab == "library")
{
<!-- Library View component -->
<LibraryView
Purchases="PurchasedRecords"
Books="CatalogBooks"
OfflineBooks="OfflineCachedBooks"
IsOfflineMode="IsOfflineMode"
OnSelectBook="HandleSelectBook"
OnReadBook="HandleReadEbook"
OnPlayBook="HandlePlayAudiobook"
OnCacheBookOffline="HandleCacheBookOffline"
/>
}
else if (ActiveTab == "author")
{
<!-- Author Portal component -->
<AuthorPortal
Categories="Categories"
OnAddCategory="HandleAddCategory"
OnPublishBook="HandlePublishBook"
/>
}
else if (ActiveTab == "terms")
{
<!-- Terms & Conditions view -->
<Terms />
}
else if (ActiveTab == "privacy")
{
<!-- Privacy Policy view -->
<Privacy />
}
<!-- Fallback or standard Blazor body placeholder -->
@Body
</main>
<!-- Detailed Book Information Modal Popup -->
@if (SelectedBook != null)
{
var isEbookOwned = PurchasedRecords.Any(p => p.BookId == SelectedBook.Id && p.Format == "ebook");
var isEbookCached = OfflineCachedBooks.Contains(SelectedBook.Id);
var isAudioOwned = PurchasedRecords.Any(p => p.BookId == SelectedBook.Id && p.Format == "audiobook");
var ebookInCart = CartItems.Any(i => i.Book.Id == SelectedBook.Id && i.Format == "ebook");
var audioInCart = CartItems.Any(i => i.Book.Id == SelectedBook.Id && i.Format == "audiobook");
<BookDetailModal
Book="SelectedBook"
IsEbookOwned="isEbookOwned"
IsEbookCached="isEbookCached"
IsAudioOwned="isAudioOwned"
EbookInCart="ebookInCart"
AudioInCart="audioInCart"
UserRating="UserRatings.ContainsKey(SelectedBook.Id) ? UserRatings[SelectedBook.Id] : (int?)null"
OnClose="HandleCloseModal"
OnAddToCart="HandleAddToCartFromModal"
OnReadEbook="() => HandleReadEbook(SelectedBook)"
OnPlayAudiobook="() => HandlePlayAudiobook(SelectedBook)"
OnRateBook="rating => HandleRateBook(SelectedBook.Id, rating)"
/>
}
<!-- Secure Checkout Side Panel Drawer -->
<CartAndCheckout
IsOpen="IsCartOpen"
OnClose="HandleCloseCart"
CartItems="CartItems"
OnRemoveFromCart="HandleRemoveFromCart"
OnCheckoutSuccess="HandleCheckoutSuccess"
/>
<!-- Embedded Proprietary Ebook Reader Overlay -->
@if (ActiveReadingBook != null)
{
<EbookReader
Book="ActiveReadingBook"
CurrentPage="ActiveReadingPage"
OnClose="HandleCloseReader"
OnPageChanged="HandlePageProgressChanged"
/>
}
<!-- Persistent Bottom Audiobook Player Node -->
@if (ActivePlayingBook != null)
{
<AudiobookPlayer
Book="ActivePlayingBook"
IsPlaying="IsAudioPlaying"
OnPlayToggle="HandleAudioPlayToggle"
OnClose="HandleCloseAudioPlayer"
/>
}
<!-- Global High Contrast Footer -->
<footer class="border-t border-white/5 bg-slate-950 mt-16 py-12 relative z-10">
<div class="max-w-7xl mx-auto px-4 sm:px-6 flex flex-col md:flex-row items-center justify-between gap-6 text-center md:text-left">
<div>
<span class="font-sans text-sm font-extrabold text-white tracking-tight uppercase">
Midrand<span class="font-light text-slate-300">books</span><span class="text-[10px] font-normal lowercase text-violet-400">.co.za</span>
</span>
<p class="text-[11px] text-slate-300 mt-1.5 font-sans">
Legal Deposit Compliant • National Library Services of South Africa • Est. 2026
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-6 text-[11px] font-mono font-bold uppercase text-slate-300 tracking-wider">
<button @onclick='() => HandleActiveTabChanged("catalog")' class="hover:text-white transition-colors cursor-pointer focus:outline-none">Catalog</button>
<button @onclick='() => HandleActiveTabChanged("terms")' class="hover:text-white transition-colors cursor-pointer focus:outline-none text-violet-400">Terms of Sale</button>
<button @onclick='() => HandleActiveTabChanged("privacy")' class="hover:text-white transition-colors cursor-pointer focus:outline-none text-violet-400">Privacy Policy</button>
<a href="#contact" class="hover:text-white transition-colors">Midrand, Gauteng</a>
</div>
<div class="flex flex-col items-center md:items-end gap-1">
<p class="text-[10px] text-slate-400 font-mono">
© 2026 Midrand Books. Securely compiled under sandbox rules.
</p>
<p class="text-[10px] text-slate-500 font-mono">
Designed, maintained, owned &amp; hosted by <a href="https://www.litecharms.co.za" target="_blank" rel="noopener noreferrer" class="text-violet-400 hover:text-violet-300 underline">LiteCharms (PTY) Ltd</a>
</p>
</div>
</div>
</footer>
<!-- Signal Interruption Reconnect Modal Overlay -->
<ReconnectModal />
<!-- Cookie Acceptance Banner and Privacy Modal -->
<CookieBanner />
</div>
@code {
private string ActiveTab { get; set; } = "catalog";
private bool IsCartOpen { get; set; } = false;
private bool IsOfflineMode { get; set; } = false;
// Master list of categories
private List<string> Categories { get; set; } = new()
{
"Fiction", "Non-Fiction", "African Literature", "Poetry", "Academic & History", "Biography"
};
// Books and States
private List<BookModel> CatalogBooks { get; set; } = new();
private List<BookModel> FeaturedBooks { get; set; } = new();
private BookModel? SelectedBook { get; set; }
private List<CartItem> CartItems { get; set; } = new();
private List<PurchaseRecord> PurchasedRecords { get; set; } = new();
private List<string> OfflineCachedBooks { get; set; } = new();
private Dictionary<string, int> UserRatings { get; set; } = new();
// Active Reader/Player Overlays
private BookModel? ActiveReadingBook { get; set; }
private int ActiveReadingPage { get; set; } = 0;
private BookModel? ActivePlayingBook { get; set; }
private bool IsAudioPlaying { get; set; } = false;
protected override void OnInitialized()
{
// Load default premium seed publications
var random = new Random(2026);
var categoriesList = new[] { "Fiction", "African Literature", "Poetry", "Academic & History", "Biography", "Non-Fiction" };
var colors = new[] {
"from-indigo-900 to-purple-950",
"from-rose-950 to-orange-900",
"from-slate-900 to-teal-950",
"from-amber-950 to-emerald-950"
};
var titles = new[] {
"Whispers of the Highveld", "Gauteng Gold Rush Chronicles", "Soweto Symphony of Dreams",
"Limpopo Rain Queen", "The Karoo Echoes", "Table Mountain Sunrise",
"Durban Beachfront Rhythms", "Zululand Warriors"
};
var authors = new[] {
"N. Khumalo", "S. Ndlovu", "T. Mokoena", "K. Naidoo", "M. van der Merwe",
"L. Sibanda", "J. Govender", "Z. Zuma"
};
var descriptions = new[] {
"An epic South African saga highlighting historical transformations, personal sacrifices, and cultural richness in the Gauteng plains.",
"A comprehensive investigative review documenting the rich socio-economic changes driven by gold discoveries in early Johannesburg.",
"A beautifully written modern poetry collection exploring life, struggle, and celebratory musical triumphs in South Africa's heartland.",
"An enchanting fantasy drawing on classical folklore, rainmaking traditions, and political intrigue in the northern provinces.",
"A mystery novel detailing hidden family histories buried deep in the silent, expansive landscapes of the great Karoo desert."
};
for (int i = 0; i < titles.Length; i++)
{
var book = new BookModel
{
Id = $"book-seed-{i + 1}",
Title = titles[i],
Author = authors[i],
AuthorId = $"seed-auth-{i + 1}",
Description = descriptions[i % descriptions.Length],
Price = random.Next(95, 295),
Category = categoriesList[i % categoriesList.Length],
CoverColor = colors[i % colors.Length],
Rating = 4.0 + random.NextDouble(),
ReviewsCount = random.Next(5, 85),
PublishedYear = random.Next(2018, 2026),
PagesCount = random.Next(120, 480),
AudioDuration = $"{random.Next(4, 12)}h {random.Next(10, 59)}m",
Narrator = authors[(i + 2) % authors.Length],
Bestseller = i < 2,
Trending = i >= 2 && i < 4,
Status = "published"
};
// Populate some dummy chapters
book.FullText = new List<string>
{
$"Chapter 1 of {book.Title}.\n\nThe morning sun breaks across the rolling hills of the Highveld, painting the Gauteng skies in shades of gold and amber. The dusty streets of Midrand begin to hum with the early morning life, as commuters fill the platforms and local merchants raise their shutters.\n\nIt was here that Khwezi first realized that the stories written in these lands had a rhythm of their own—a heartbeat that echoed the rich histories of those who walked before.",
"Chapter 2.\n\nAs the afternoon heat settles over the vast landscapes, a quiet stillness descends. Under the shade of an ancient thorn tree, old journals are opened, revealing handwritten accounts of early migrations, family secrets, and triumphant breakthroughs. The digital DRM safeguards preserve these fragile pages for generations to come."
};
CatalogBooks.Add(book);
}
FeaturedBooks = CatalogBooks.Where(b => b.Bestseller || b.Trending).Take(3).ToList();
// Pre-purchase one book to show active items in Library immediately
PurchasedRecords.Add(new PurchaseRecord
{
BookId = "book-seed-3",
Format = "ebook",
CurrentPage = 0,
Progress = 15
});
}
private void HandleActiveTabChanged(string tab)
{
ActiveTab = tab;
}
private void HandleOpenCart()
{
IsCartOpen = true;
}
private void HandleCloseCart()
{
IsCartOpen = false;
}
private void HandleOfflineToggle(bool value)
{
IsOfflineMode = value;
}
private void HandleSelectBook(BookModel book)
{
SelectedBook = book;
}
private void HandleCloseModal()
{
SelectedBook = null;
}
private void HandleAddToCart((BookModel Book, string Format) tuple)
{
if (!CartItems.Any(i => i.Book.Id == tuple.Book.Id && i.Format == tuple.Format))
{
CartItems.Add(new CartItem { Book = tuple.Book, Format = tuple.Format });
}
}
private void HandleAddToCartFromModal(string format)
{
if (SelectedBook != null)
{
HandleAddToCart((SelectedBook, format));
}
}
private void HandleRemoveFromCart(CartItem item)
{
CartItems.Remove(item);
}
private void HandleCheckoutSuccess(List<CartItem> items)
{
foreach (var item in items)
{
if (!PurchasedRecords.Any(p => p.BookId == item.Book.Id && p.Format == item.Format))
{
PurchasedRecords.Add(new PurchaseRecord
{
BookId = item.Book.Id,
Format = item.Format,
CurrentPage = 0,
Progress = 0
});
}
}
CartItems.Clear();
ActiveTab = "library";
}
private void HandleReadEbook(BookModel book)
{
ActiveReadingBook = book;
var purchase = PurchasedRecords.FirstOrDefault(p => p.BookId == book.Id && p.Format == "ebook");
ActiveReadingPage = purchase?.CurrentPage ?? 0;
SelectedBook = null; // Close detail modal if open
}
private void HandleCloseReader()
{
ActiveReadingBook = null;
}
private void HandlePageProgressChanged(int page)
{
ActiveReadingPage = page;
if (ActiveReadingBook != null)
{
var purchase = PurchasedRecords.FirstOrDefault(p => p.BookId == ActiveReadingBook.Id && p.Format == "ebook");
if (purchase != null)
{
purchase.CurrentPage = page;
}
}
}
private void HandlePlayAudiobook(BookModel book)
{
ActivePlayingBook = book;
IsAudioPlaying = true;
SelectedBook = null; // Close detail modal if open
}
private void HandleCloseAudioPlayer()
{
ActivePlayingBook = null;
IsAudioPlaying = false;
}
private void HandleAudioPlayToggle(bool isPlaying)
{
IsAudioPlaying = isPlaying;
}
private void HandleCacheBookOffline(string bookId)
{
if (!OfflineCachedBooks.Contains(bookId))
{
OfflineCachedBooks.Add(bookId);
}
}
private void HandleAddCategory(string category)
{
if (!Categories.Contains(category))
{
Categories.Add(category);
}
}
private void HandlePublishBook(BookModel book)
{
CatalogBooks.Insert(0, book);
}
private void HandleRateBook(string bookId, int rating)
{
int? oldRating = UserRatings.ContainsKey(bookId) ? UserRatings[bookId] : null;
UserRatings[bookId] = rating;
var book = CatalogBooks.FirstOrDefault(b => b.Id == bookId);
if (book != null)
{
double oldTotal = book.Rating * book.ReviewsCount;
if (oldRating.HasValue)
{
book.Rating = (oldTotal - oldRating.Value + rating) / Math.Max(1, book.ReviewsCount);
}
else
{
book.ReviewsCount += 1;
book.Rating = (oldTotal + rating) / book.ReviewsCount;
}
book.Rating = Math.Round(book.Rating * 10) / 10;
}
}
}
+240
View File
@@ -0,0 +1,240 @@
@namespace MidrandBooks.Components
@using Microsoft.AspNetCore.Components
@using MidrandBooks.Models
<nav class="sticky top-0 z-40 border-b border-slate-100 bg-white/95 backdrop-blur-md px-4 sm:px-6 py-4">
<div class="max-w-7xl mx-auto flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<!-- Brand Logo & South African Context -->
<div class="flex items-center justify-between">
<div @onclick='() => SetTab("catalog")' class="flex items-center gap-2.5 cursor-pointer group">
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-900 text-white shadow-sm group-hover:scale-105 transition-transform">
<svg class="h-5 w-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<div>
<span class="font-sans text-xl font-bold tracking-tight text-slate-800 uppercase">
Midrand<span class="font-light text-slate-500">books</span><span class="text-xs font-normal lowercase text-violet-500">.co.za</span>
</span>
<p class="text-[9px] text-slate-400 tracking-wider uppercase font-mono">Est. 2026 • South Africa</p>
</div>
</div>
<!-- Quick Mobile Action Icons -->
<div class="flex items-center gap-2 md:hidden">
<button
@onclick="ToggleOfflineMode"
class="p-2 rounded-lg border @(IsOfflineMode ? "border-amber-500/20 bg-amber-50/80 text-amber-600" : "border-emerald-500/20 bg-emerald-50/80 text-emerald-600")"
title="@(IsOfflineMode ? "Offline Mode Active" : "Online Mode")">
@if (IsOfflineMode)
{
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-3.536 5 5 0 011.414-3.536M4.93 19.07a9 9 0 010-12.728M12 12v.01" />
</svg>
}
else
{
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
}
</button>
<button
@onclick="HandleOpenCart"
class="relative p-2 rounded-lg border border-slate-200 bg-slate-50 text-slate-700 hover:bg-slate-100">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
@if (CartCount > 0)
{
<span class="absolute -top-1 -right-1 flex h-4.5 w-4.5 items-center justify-center rounded-full bg-slate-900 text-[10px] font-bold text-white">
@CartCount
</span>
}
</button>
</div>
</div>
<!-- Search Bar Node and Desktop Navigation links -->
<div class="flex-1 flex flex-col md:flex-row items-stretch md:items-center justify-end gap-4">
<!-- Dynamic Search input block -->
<div class="relative flex-1 max-w-md" @onfocusout="HandleFocusOut">
<span class="absolute inset-y-0 left-3 flex items-center text-slate-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</span>
<input
type="text"
placeholder="Search over 1,500 local publications..."
value="@SearchQuery"
@oninput="HandleSearchInput"
@onfocus="() => ShowResults = true"
class="w-full rounded-xl border border-slate-200 bg-slate-50 pl-9 pr-4 py-2 text-xs text-slate-800 placeholder-slate-400 outline-none focus:bg-white focus:ring-2 focus:ring-violet-500/15 focus:border-violet-500 transition-all font-sans"
/>
<!-- Floating Search Results Drawer -->
@if (ShowResults && FilteredBooks().Any())
{
<div class="absolute left-0 right-0 mt-2 rounded-2xl border border-slate-200/80 bg-white p-2 shadow-xl z-50">
<div class="px-3 py-1.5 border-b border-slate-50">
<span class="text-[9px] font-mono font-bold tracking-wider text-slate-400 uppercase">Interactive Catalog Search Results</span>
</div>
<div class="divide-y divide-slate-50 max-h-[300px] overflow-y-auto">
@foreach (var book in FilteredBooks())
{
<div @onclick="() => ClickSearchResult(book)" class="p-2.5 hover:bg-slate-50 rounded-xl flex items-center gap-3 cursor-pointer transition-colors text-left">
<div class="h-10 w-7 rounded bg-slate-100 overflow-hidden flex-shrink-0 shadow-sm border border-slate-100">
@if (!string.IsNullOrEmpty(book.CoverUrl))
{
<img src="@book.CoverUrl" alt="@book.Title" class="h-full w-full object-cover" />
}
else
{
<div class="h-full w-full bg-gradient-to-br @book.CoverColor flex items-center justify-center">
<span class="text-[6px] text-white/50">BOOK</span>
</div>
}
</div>
<div class="min-w-0 flex-1">
<span class="block text-xs font-bold text-slate-800 truncate">@book.Title</span>
<span class="block text-[10px] text-slate-400 truncate">By @book.Author • <span class="font-mono text-[9px] text-violet-500 font-bold uppercase">@book.Category</span></span>
</div>
</div>
}
</div>
</div>
}
</div>
<!-- Central Platform Tabs navigation -->
<div class="flex items-center justify-around md:justify-end gap-1 border-t md:border-t-0 border-slate-100 pt-3 md:pt-0">
<button
@onclick='() => SetTab("catalog")'
class="px-4 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-2 cursor-pointer @(ActiveTab == "catalog" ? "bg-slate-900 text-white shadow-sm" : "text-slate-500 hover:text-slate-800 hover:bg-slate-50")">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Browse Store
</button>
<button
@onclick='() => SetTab("library")'
class="px-4 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-2 cursor-pointer @(ActiveTab == "library" ? "bg-slate-900 text-white shadow-sm" : "text-slate-500 hover:text-slate-800 hover:bg-slate-50")">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z" />
</svg>
My Library
</button>
<button
@onclick='() => SetTab("author")'
class="px-4 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-2 cursor-pointer @(ActiveTab == "author" ? "bg-slate-900 text-white shadow-sm" : "text-slate-500 hover:text-slate-800 hover:bg-slate-50")">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.907c.961 0 1.364 1.241.588 1.81l-3.974 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.974-2.888c-.776-.569-.373-1.81.588-1.81h4.906a1 1 0 00.95-.69l1.519-4.674z" />
</svg>
Author Studio
</button>
<!-- Desktop Quick Actions -->
<div class="hidden md:flex items-center gap-2 border-l border-slate-100 pl-4 ml-1">
<button
@onclick="ToggleOfflineMode"
class="p-2 rounded-xl border transition-colors cursor-pointer @(IsOfflineMode ? "border-amber-500/20 bg-amber-50/80 text-amber-600 hover:bg-amber-100/80" : "border-emerald-500/20 bg-emerald-50/80 text-emerald-600 hover:bg-emerald-100/80")"
title="@(IsOfflineMode ? "Offline Mode Active" : "Online Mode Live")">
@if (IsOfflineMode)
{
<svg class="h-4.5 w-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-3.536 5 5 0 011.414-3.536M4.93 19.07a9 9 0 010-12.728M12 12v.01" />
</svg>
}
else
{
<svg class="h-4.5 w-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
}
</button>
<button
@onclick="HandleOpenCart"
class="relative p-2 rounded-xl border border-slate-200 bg-slate-50 text-slate-700 hover:bg-slate-100 transition-all cursor-pointer">
<svg class="h-4.5 w-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
@if (CartCount > 0)
{
<span class="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-slate-900 text-[10px] font-bold text-white shadow-sm border border-white">
@CartCount
</span>
}
</button>
</div>
</div>
</div>
</div>
</nav>
@code {
[Parameter] public string ActiveTab { get; set; } = "catalog";
[Parameter] public EventCallback<string> OnActiveTabChanged { get; set; }
[Parameter] public EventCallback OnOpenCart { get; set; }
[Parameter] public EventCallback<BookModel> OnSelectBook { get; set; }
[Parameter] public List<BookModel> Books { get; set; } = new();
[Parameter] public int CartCount { get; set; }
[Parameter] public bool IsOfflineMode { get; set; }
[Parameter] public EventCallback<bool> OnOfflineModeToggle { get; set; }
private string SearchQuery { get; set; } = string.Empty;
private bool ShowResults { get; set; } = false;
private void SetTab(string tab)
{
OnActiveTabChanged.InvokeAsync(tab);
}
private void ToggleOfflineMode()
{
OnOfflineModeToggle.InvokeAsync(!IsOfflineMode);
}
private void HandleOpenCart()
{
OnOpenCart.InvokeAsync();
}
private void HandleSearchInput(ChangeEventArgs e)
{
SearchQuery = e.Value?.ToString() ?? string.Empty;
ShowResults = !string.IsNullOrWhiteSpace(SearchQuery);
}
private IEnumerable<BookModel> FilteredBooks()
{
if (string.IsNullOrWhiteSpace(SearchQuery)) return Enumerable.Empty<BookModel>();
var query = SearchQuery.Trim().ToLower();
return Books
.Where(b => b.Status == "published" &&
(b.Title.ToLower().Contains(query) ||
b.Author.ToLower().Contains(query) ||
b.Category.ToLower().Contains(query)))
.Take(5);
}
private void ClickSearchResult(BookModel book)
{
OnSelectBook.InvokeAsync(book);
SearchQuery = string.Empty;
ShowResults = false;
}
private async Task HandleFocusOut()
{
// Simple delay to allow click events on items to execute before drawer closes
await Task.Delay(200);
ShowResults = false;
}
}
@@ -0,0 +1,205 @@
@namespace MidrandBooks.Components.Layout
@using Microsoft.AspNetCore.Components
@inject IJSRuntime JSRuntime
<dialog id="components-reconnect-modal" data-nosnippet class="fixed inset-0 w-screen h-screen m-0 p-0 border-0 bg-transparent z-[99999] overflow-hidden flex items-center justify-center opacity-0 pointer-events-none transition-opacity duration-300">
<!-- Dark Frosted Jacket Layer -->
<div class="absolute inset-0 bg-slate-950/60 backdrop-blur-md cursor-wait z-[1] animate-in fade-in duration-300"></div>
<!-- Centered Content Wrapper -->
<div class="reconnect-content relative z-[2] flex flex-col items-center gap-6 text-center text-slate-100 max-w-sm px-6">
<!-- Waveform Animation -->
<svg class="waveform-svg text-violet-400" viewBox="0 0 54 50" width="60" height="60" xmlns="http://www.w3.org/2000/svg">
<rect class="bar bar-1" x="2" y="15" width="4" height="20" rx="2" fill="currentColor" />
<rect class="bar bar-2" x="14" y="5" width="4" height="40" rx="2" fill="currentColor" />
<rect class="bar bar-3" x="26" y="20" width="4" height="10" rx="2" fill="currentColor" />
<rect class="bar bar-4" x="38" y="10" width="4" height="30" rx="2" fill="currentColor" />
<rect class="bar bar-5" x="50" y="18" width="4" height="14" rx="2" fill="currentColor" />
</svg>
<!-- Message Body -->
<div class="sync-message-body font-mono uppercase text-xs tracking-widest text-slate-300 space-y-2">
<span class="components-reconnect-first-attempt-visible hidden">
Losing signal...
</span>
<span class="components-reconnect-repeated-attempt-visible hidden">
reconnecting in <span id="components-seconds-to-next-attempt" class="font-bold text-violet-400">0</span>s...
</span>
<span class="components-reconnect-failed-visible text-rose-400 font-semibold hidden">
Playback disconnected.
</span>
<span class="components-pause-visible hidden">
Reading layout paused.
</span>
<span class="components-resume-failed-visible text-rose-400 font-semibold hidden">
State alignment broken.
</span>
</div>
<!-- Glass Action Buttons -->
<div class="sync-action-node flex items-center justify-center gap-3">
<button id="components-reconnect-button" class="btn-glass-action hidden px-4 py-2.5 bg-white/5 border border-white/10 hover:bg-white/10 text-white font-mono uppercase text-[10px] tracking-widest rounded-xl transition-all items-center gap-2 cursor-pointer shadow-lg hover:scale-102 hover:border-violet-500/30">
<span>Retry Sync</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67" />
</svg>
</button>
<button id="components-resume-button" class="btn-glass-action hidden px-4 py-2.5 bg-white/5 border border-white/10 hover:bg-white/10 text-white font-mono uppercase text-[10px] tracking-widest rounded-xl transition-all items-center gap-2 cursor-pointer shadow-lg hover:scale-102 hover:border-violet-500/30">
<span>Reload Session</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
</button>
</div>
</div>
</dialog>
<style>
/* --- Show the modal smoothly when Blazor attaches state classes --- */
#components-reconnect-modal.components-reconnect-show,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-rejected {
opacity: 1 !important;
pointer-events: all !important;
}
#components-reconnect-modal.components-reconnect-hide {
display: none !important;
}
/* --- SVG Waveform Animation --- */
#components-reconnect-modal .bar {
transform-origin: center;
animation: reconnectWavePulse 1.2s ease-in-out infinite alternate;
}
#components-reconnect-modal .bar-1 { animation-delay: 0.0s; }
#components-reconnect-modal .bar-2 { animation-delay: 0.3s; }
#components-reconnect-modal .bar-3 { animation-delay: 0.6s; }
#components-reconnect-modal .bar-4 { animation-delay: 0.2s; }
#components-reconnect-modal .bar-5 { animation-delay: 0.5s; }
@@keyframes reconnectWavePulse {
0% {
transform: scaleY(0.3);
opacity: 0.5;
}
100% {
transform: scaleY(1);
opacity: 1;
}
}
/* Stop the waveform if the connection completely fails */
#components-reconnect-modal.components-reconnect-failed .bar,
#components-reconnect-modal.components-reconnect-rejected .bar,
#components-reconnect-modal.components-reconnect-paused .bar {
animation: none !important;
transform: scaleY(0.1) !important;
opacity: 0.3 !important;
}
/* --- Display Mechanics Matrix Controllers --- */
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: inline-block !important;
}
#components-reconnect-modal.components-reconnect-failed .btn-glass-action,
#components-reconnect-modal.components-reconnect-paused .btn-glass-action,
#components-reconnect-modal.components-reconnect-resume-failed .btn-glass-action {
display: inline-flex !important;
}
</style>
<script>
(function() {
function initReconnectModal() {
const reconnectModal = document.getElementById("components-reconnect-modal");
if (!reconnectModal) {
setTimeout(initReconnectModal, 100);
return;
}
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
if (retryButton) {
retryButton.addEventListener("click", retry);
}
const resumeButton = document.getElementById("components-resume-button");
if (resumeButton) {
resumeButton.addEventListener("click", resume);
}
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
if (window.Blazor) {
const successful = await window.Blazor.reconnect();
if (!successful) {
const resumeSuccessful = await window.Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
}
} catch (err) {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
if (window.Blazor) {
const successful = await window.Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initReconnectModal);
} else {
initReconnectModal();
}
})();
</script>