Polish ayah UX with fixed player dock and progressive skeletons.
build-and-deploy / deploy (push) Successful in 4m45s

Compact header, sidebar surah list, closed notes, resume CTA, and larger ayah typography.
This commit is contained in:
2026-10-10 02:15:35 +03:30
parent f95c118efb
commit 3ca743ca12
11 changed files with 567 additions and 178 deletions
+171 -149
View File
@@ -4,111 +4,128 @@
@inject TafsirApiClient Api
@inject IDeviceIdStore DeviceStore
@inject NavigationManager Nav
@inject PlayerSessionService Player
<section class="tafsir-page">
<header class="page-header">
<h1>@title</h1>
<p class="muted">@author</p>
@if (view == View.Ayahs || view == View.Detail)
<header class="page-header page-header-row">
@if (loadingMeta)
{
<button type="button" class="linkish" @onclick="BackToSurahs">← فهرست سوره‌ها</button>
<SkeletonBlock Variant="lines" Lines="1" />
}
else
{
<h1 class="page-title-inline">
<span>@title</span>
@if (!string.IsNullOrWhiteSpace(author))
{
<span class="title-author">(@author)</span>
}
</h1>
<button type="button" class="back-btn" @onclick="GoBack" aria-label="بازگشت">←</button>
}
</header>
@if (loading)
@if (view == View.Surahs)
{
<p class="muted">در حال بارگذاری...</p>
}
else if (view == View.Surahs)
{
<div class="surah-grid">
@foreach (var s in surahs)
{
<div class="surah-card">
<button type="button" class="surah-main" @onclick="() => OpenSurah(s.Number)">
<span class="surah-num">@s.Number</span>
<span class="surah-names">
<strong>@s.NameFa</strong>
<span class="muted">@s.NameAr</span>
</span>
<span class="muted">@s.AyahCount آیه</span>
</button>
<button type="button" class="chip @(s.IsBookmarked ? "on" : null)"
@onclick="() => ToggleSurahBookmark(s)">نشان</button>
</div>
}
</div>
@if (loadingSurahs)
{
<SkeletonBlock Variant="cards" Lines="8" />
}
else
{
<div class="surah-grid">
@foreach (var s in surahs)
{
<div class="surah-card">
<button type="button" class="surah-main" @onclick="() => OpenSurah(s.Number)">
<span class="surah-num">@s.Number</span>
<span class="surah-names">
<strong>@s.NameFa</strong>
<span class="muted">@s.NameAr</span>
</span>
<span class="muted">@s.AyahCount آیه</span>
</button>
<button type="button" class="chip @(s.IsBookmarked ? "on" : null)"
@onclick="() => ToggleSurahBookmark(s)">نشان</button>
</div>
}
</div>
}
}
else if (view == View.Ayahs)
{
<h2 class="section-title">سوره @selectedSurah?.NameFa</h2>
<div class="ayah-list">
@foreach (var a in ayahs)
{
<button type="button" class="ayah-row" @onclick="() => OpenAyah(a.AyahNumber)">
<span class="ayah-num">@a.AyahNumber</span>
<span class="ayah-preview">@(a.TextFaPreview ?? "آیه " + a.AyahNumber)</span>
<span class="ayah-flags">
@if (a.IsFavorite) { <span class="dot fav" title="علاقه"></span> }
@if (a.IsBookmarked) { <span class="dot mark" title="نشان"></span> }
@if (a.HasAudio) { <span class="dot audio" title="صوت"></span> }
</span>
</button>
}
</div>
<h2 class="section-title">سوره @(selectedSurah?.NameFa ?? "…")</h2>
@if (loadingAyahs)
{
<SkeletonBlock Variant="cards" Lines="10" />
}
else
{
<div class="ayah-list">
@foreach (var a in ayahs)
{
<button type="button" class="ayah-row" @onclick="() => OpenAyah(a.AyahNumber)">
<span class="ayah-num">@a.AyahNumber</span>
<span class="ayah-preview">@(a.TextFaPreview ?? "آیه " + a.AyahNumber)</span>
<span class="ayah-flags">
@if (a.IsFavorite) { <span class="dot fav" title="علاقه"></span> }
@if (a.IsBookmarked) { <span class="dot mark" title="نشان"></span> }
@if (a.HasAudio) { <span class="dot audio" title="صوت"></span> }
</span>
</button>
}
</div>
}
}
else if (detail is not null)
else if (view == View.Detail)
{
<div class="ayah-detail">
<div class="ayah-nav">
<button type="button" class="chip" disabled="@(detail.PrevAyah is null)"
@onclick="() => OpenAyah(detail.PrevAyah!.Value)">آیه قبل</button>
<button type="button" class="chip" @onclick="() => OpenSurah(detail.SurahNumber)">فهرست آیات</button>
<button type="button" class="chip" disabled="@(detail.NextAyah is null)"
@onclick="() => OpenAyah(detail.NextAyah!.Value)">آیه بعد</button>
<button type="button" class="chip" disabled="@(detail?.PrevAyah is null)"
@onclick="() => GoPrevAyah()">آیه قبل</button>
<button type="button" class="chip" @onclick="() => OpenSurah(SurahNumber ?? 1)">فهرست آیات</button>
<button type="button" class="chip" disabled="@(detail?.NextAyah is null)"
@onclick="() => GoNextAyah()">آیه بعد</button>
</div>
<div class="ayah-block">
<p class="ayah-ar">@detail.TextAr</p>
@if (!string.IsNullOrWhiteSpace(detail.TextFa))
@if (loadingDetail || detail is null)
{
<SkeletonBlock Variant="lines" Lines="5" />
<SkeletonBlock Variant="cards" Lines="2" />
}
else
{
<div class="ayah-block">
<p class="ayah-ar">@detail.TextAr</p>
@if (!string.IsNullOrWhiteSpace(detail.TextFa))
{
<p class="ayah-fa">@detail.TextFa</p>
}
</div>
@if (!string.IsNullOrWhiteSpace(detail.NotesHtml))
{
<p class="ayah-fa">@detail.TextFa</p>
<details class="content-block">
<summary>نکته‌ها</summary>
<div class="html-body">@((MarkupString)detail.NotesHtml)</div>
</details>
}
@if (!string.IsNullOrWhiteSpace(detail.MessagesHtml))
{
<details class="content-block">
<summary>پیام‌ها</summary>
<div class="html-body">@((MarkupString)detail.MessagesHtml)</div>
</details>
}
@if (!string.IsNullOrWhiteSpace(detail.VideoEmbedUrl))
{
<details class="content-block">
<summary>فیلم</summary>
<div class="video-wrap">
<iframe src="@detail.VideoEmbedUrl" allowfullscreen loading="lazy"></iframe>
</div>
</details>
}
</div>
<AudioPlayer @key="@($"{detail.SurahNumber}:{detail.AyahNumber}")"
Title="@($"{detail.SurahNameFa} — آیه {detail.AyahNumber}")"
AudioUrl="@detail.AudioUrl"
AudioKey="@($"{detail.SurahNumber}:{detail.AyahNumber}")"
InitialPosition="initialPosition"
IsFavorite="isFavorite"
IsBookmarked="isBookmarked"
OnFavoriteToggle="ToggleFavorite"
OnBookmarkToggle="ToggleBookmark"
OnProgress="SaveProgress" />
@if (!string.IsNullOrWhiteSpace(detail.NotesHtml))
{
<details class="content-block" open>
<summary>نکته‌ها</summary>
<div class="html-body">@((MarkupString)detail.NotesHtml)</div>
</details>
}
@if (!string.IsNullOrWhiteSpace(detail.MessagesHtml))
{
<details class="content-block" open>
<summary>پیام‌ها</summary>
<div class="html-body">@((MarkupString)detail.MessagesHtml)</div>
</details>
}
@if (!string.IsNullOrWhiteSpace(detail.VideoEmbedUrl))
{
<details class="content-block">
<summary>فیلم</summary>
<div class="video-wrap">
<iframe src="@detail.VideoEmbedUrl" allowfullscreen loading="lazy"></iframe>
</div>
</details>
}
</div>
}
@@ -124,16 +141,16 @@
private string title = "تفسیر";
private string author = "";
private Guid tafsirId;
private bool loading = true;
private bool loadingMeta = true;
private bool loadingSurahs;
private bool loadingAyahs;
private bool loadingDetail;
private string? routeKey;
private View view = View.Surahs;
private List<SurahListItemDto> surahs = [];
private List<AyahListItemDto> ayahs = [];
private SurahListItemDto? selectedSurah;
private AyahDetailDto? detail;
private double initialPosition;
private bool isFavorite;
private bool isBookmarked;
private string deviceId = "";
private bool ready;
@@ -157,7 +174,15 @@
if (routeKey == key) return;
routeKey = key;
loading = true;
if (AyahNumber is not null && SurahNumber is not null) view = View.Detail;
else if (SurahNumber is not null) view = View.Ayahs;
else view = View.Surahs;
loadingMeta = true;
loadingSurahs = view == View.Surahs;
loadingAyahs = view == View.Ayahs;
loadingDetail = view == View.Detail;
detail = null;
await InvokeAsync(StateHasChanged);
try
@@ -166,37 +191,39 @@
deviceId = await DeviceStore.GetOrCreateAsync();
var tafsirs = await Api.GetTafsirsAsync();
var tafsir = tafsirs.FirstOrDefault(t => t.Slug == slug);
var tafsir = tafsirs.FirstOrDefault(t => t.Slug == Slug);
title = tafsir?.Title ?? "تفسیر";
author = tafsir?.Author ?? "";
tafsirId = tafsir?.Id ?? Guid.Empty;
loadingMeta = false;
await InvokeAsync(StateHasChanged);
if (AyahNumber is int a && SurahNumber is int s)
{
view = View.Detail;
if (view == View.Detail && SurahNumber is int s && AyahNumber is int a)
await LoadDetail(s, a);
}
else if (SurahNumber is int sn)
{
view = View.Ayahs;
else if (view == View.Ayahs && SurahNumber is int sn)
await LoadAyahs(sn);
}
else
{
view = View.Surahs;
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
}
await LoadSurahs();
}
finally
{
loading = false;
loadingMeta = false;
loadingSurahs = false;
loadingAyahs = false;
loadingDetail = false;
await InvokeAsync(StateHasChanged);
}
}
private string slug => Slug;
private void BackToSurahs() => Nav.NavigateTo($"/tafsir/{Slug}");
private void GoBack()
{
if (view == View.Detail && SurahNumber is int s)
Nav.NavigateTo($"/tafsir/{Slug}/surah/{s}");
else if (view == View.Ayahs)
Nav.NavigateTo($"/tafsir/{Slug}");
else
Nav.NavigateTo("/");
}
private void OpenSurah(int n) => Nav.NavigateTo($"/tafsir/{Slug}/surah/{n}");
@@ -206,30 +233,52 @@
Nav.NavigateTo($"/tafsir/{Slug}/surah/{s}/ayah/{n}");
}
private void GoPrevAyah()
{
if (detail?.PrevAyah is int p) OpenAyah(p);
}
private void GoNextAyah()
{
if (detail?.NextAyah is int n) OpenAyah(n);
}
private async Task LoadSurahs()
{
loadingSurahs = true;
await InvokeAsync(StateHasChanged);
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
loadingSurahs = false;
}
private async Task LoadAyahs(int surahNumber)
{
surahs = surahs.Count > 0 ? surahs : (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
loadingAyahs = true;
await InvokeAsync(StateHasChanged);
if (surahs.Count == 0)
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
selectedSurah = surahs.FirstOrDefault(x => x.Number == surahNumber);
ayahs = (await Api.GetAyahsAsync(Slug, surahNumber, deviceId)).ToList();
loadingAyahs = false;
}
private async Task LoadDetail(int surahNumber, int ayahNumber)
{
loadingDetail = true;
await InvokeAsync(StateHasChanged);
detail = await Api.GetAyahAsync(Slug, surahNumber, ayahNumber);
loadingDetail = false;
await InvokeAsync(StateHasChanged);
if (detail is null) return;
var progress = await Api.GetProgressAsync(deviceId);
initialPosition = progress is not null
&& progress.SurahNumber == surahNumber
&& progress.AyahNumber == ayahNumber
? progress.PositionSeconds
: 0;
var favorites = await Api.GetFavoritesAsync(deviceId);
var bookmarks = await Api.GetAyahBookmarksAsync(deviceId);
isFavorite = favorites.Any(f => f.TafsirId == detail.TafsirId && f.SurahNumber == surahNumber && f.AyahNumber == ayahNumber);
isBookmarked = bookmarks.Any(b => b.TafsirId == detail.TafsirId && b.SurahNumber == surahNumber && b.AyahNumber == ayahNumber);
tafsirId = detail.TafsirId;
var resumePos = Player.HasTrack
&& Player.SurahNumber == surahNumber
&& Player.AyahNumber == ayahNumber
? Player.PositionSeconds
: 0;
await Player.SetTrackAsync(detail, resumePos);
}
private async Task ToggleSurahBookmark(SurahListItemDto s)
@@ -241,31 +290,4 @@
await Api.AddSurahBookmarkAsync(new ToggleSurahBookmarkRequest(tafsirId, s.Number, deviceId));
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId)).ToList();
}
private async Task ToggleFavorite()
{
if (detail is null) return;
if (isFavorite)
await Api.RemoveFavoriteAsync(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId);
else
await Api.AddFavoriteAsync(new ToggleAyahRequest(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId));
isFavorite = !isFavorite;
}
private async Task ToggleBookmark()
{
if (detail is null) return;
if (isBookmarked)
await Api.RemoveAyahBookmarkAsync(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId);
else
await Api.AddAyahBookmarkAsync(new ToggleAyahRequest(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId));
isBookmarked = !isBookmarked;
}
private async Task SaveProgress(double position)
{
if (detail is null) return;
await Api.SaveProgressAsync(new UpsertProgressRequest(
detail.TafsirId, detail.SurahNumber, detail.AyahNumber, position, deviceId));
}
}