Add Surah/Ayah browse with Gharaati Tafsir Noor crawl.
build-and-deploy / deploy (push) Failing after 11m0s

Replace episode bookmarks with surah/ayah bookmarks and ayah favorites;
import ayah text, notes, messages, and official per-ayah audio from gharaati.ir.
This commit is contained in:
2026-10-10 00:24:02 +03:30
parent d8189fab9e
commit 9309a1e6de
25 changed files with 2340 additions and 363 deletions
+188 -65
View File
@@ -1,71 +1,136 @@
@page "/tafsir/{Slug}"
@page "/tafsir/{Slug}/surah/{SurahNumber:int}"
@page "/tafsir/{Slug}/surah/{SurahNumber:int}/ayah/{AyahNumber:int}"
@inject TafsirApiClient Api
@inject IDeviceIdStore DeviceStore
@inject NavigationManager Nav
<section class="tafsir-page">
<header class="page-header">
<h1>@title</h1>
<p class="muted">@author</p>
@if (view == View.Ayahs || view == View.Detail)
{
<button type="button" class="linkish" @onclick="BackToSurahs">← فهرست سوره‌ها</button>
}
</header>
@if (loading)
{
<p class="muted">در حال بارگذاری...</p>
}
else
else if (view == View.Surahs)
{
<div class="episode-layout">
<div class="episode-list">
@foreach (var ep in episodes)
{
<button type="button"
class="episode-row @(selected?.Id == ep.Id ? "active" : null)"
@onclick="() => SelectAsync(ep.Id)">
<span class="ep-title">@ep.Title</span>
<span class="ep-meta">@ep.SurahNameFa — آیه @ep.AyahNumber</span>
<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>
}
else if (detail is not null)
{
<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>
</div>
<div class="ayah-block">
<p class="ayah-ar">@detail.TextAr</p>
@if (!string.IsNullOrWhiteSpace(detail.TextFa))
{
<p class="ayah-fa">@detail.TextFa</p>
}
</div>
<div class="player-panel">
@if (detail is null)
{
<p class="muted">یک آیه را انتخاب کنید</p>
}
else
{
<div class="ayah-block">
<p class="ayah-ar">@detail.AyahTextAr</p>
@if (!string.IsNullOrWhiteSpace(detail.AyahTextFa))
{
<p class="ayah-fa">@detail.AyahTextFa</p>
}
<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>
<AudioPlayer @key="detail.Id"
Episode="detail"
InitialPosition="initialPosition"
IsFavorite="isFavorite"
IsBookmarked="isBookmarked"
OnFavoriteToggle="ToggleFavorite"
OnBookmarkToggle="ToggleBookmark"
OnProgress="SaveProgress" />
}
</div>
</details>
}
</div>
}
</section>
@code {
private enum View { Surahs, Ayahs, Detail }
[Parameter] public string Slug { get; set; } = "";
[Parameter] public int? SurahNumber { get; set; }
[Parameter] public int? AyahNumber { get; set; }
private string title = "تفسیر";
private string author = "";
private Guid tafsirId;
private bool loading = true;
private string? loadedForSlug;
private List<EpisodeListItemDto> episodes = [];
private EpisodeListItemDto? selected;
private EpisodeDetailDto? detail;
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;
@@ -74,31 +139,52 @@
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender) ready = true;
if (!ready || string.IsNullOrWhiteSpace(Slug) || loadedForSlug == Slug) return;
if (!firstRender) return;
ready = true;
deviceId = await DeviceStore.GetOrCreateAsync();
await LoadRouteAsync();
}
protected override async Task OnParametersSetAsync()
{
if (!ready) return;
await LoadRouteAsync();
}
private async Task LoadRouteAsync()
{
var key = $"{Slug}|{SurahNumber}|{AyahNumber}";
if (routeKey == key) return;
routeKey = key;
loading = true;
loadedForSlug = Slug;
deviceId = await DeviceStore.GetOrCreateAsync();
await InvokeAsync(StateHasChanged);
try
{
if (string.IsNullOrWhiteSpace(deviceId))
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;
episodes = (await Api.GetEpisodesAsync(Slug)).ToList();
var progress = await Api.GetProgressAsync(deviceId);
var startId = progress?.Episode?.Id;
if (startId is null || episodes.All(e => e.Id != startId))
startId = episodes.FirstOrDefault()?.Id;
if (startId is Guid id)
if (AyahNumber is int a && SurahNumber is int s)
{
initialPosition = progress?.EpisodeId == id ? progress.PositionSeconds : 0;
await SelectAsync(id, keepPosition: true);
view = View.Detail;
await LoadDetail(s, a);
}
else if (SurahNumber is int sn)
{
view = View.Ayahs;
await LoadAyahs(sn);
}
else
{
view = View.Surahs;
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
}
}
finally
@@ -108,25 +194,61 @@
}
}
private async Task SelectAsync(Guid id, bool keepPosition = false)
private string slug => Slug;
private void BackToSurahs() => Nav.NavigateTo($"/tafsir/{Slug}");
private void OpenSurah(int n) => Nav.NavigateTo($"/tafsir/{Slug}/surah/{n}");
private void OpenAyah(int n)
{
selected = episodes.FirstOrDefault(e => e.Id == id);
detail = await Api.GetEpisodeAsync(id);
if (!keepPosition) initialPosition = 0;
var s = SurahNumber ?? detail?.SurahNumber ?? selectedSurah?.Number ?? 1;
Nav.NavigateTo($"/tafsir/{Slug}/surah/{s}/ayah/{n}");
}
private async Task LoadAyahs(int surahNumber)
{
surahs = 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();
}
private async Task LoadDetail(int surahNumber, int ayahNumber)
{
detail = await Api.GetAyahAsync(Slug, surahNumber, ayahNumber);
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.GetBookmarksAsync(deviceId);
isFavorite = favorites.Any(f => f.EpisodeId == id);
isBookmarked = bookmarks.Any(b => b.EpisodeId == id);
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;
}
private async Task ToggleSurahBookmark(SurahListItemDto s)
{
if (tafsirId == Guid.Empty) return;
if (s.IsBookmarked)
await Api.RemoveSurahBookmarkAsync(tafsirId, s.Number, deviceId);
else
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.Id, deviceId);
await Api.RemoveFavoriteAsync(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId);
else
await Api.AddFavoriteAsync(new ToggleEpisodeRequest(detail.Id, deviceId));
await Api.AddFavoriteAsync(new ToggleAyahRequest(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId));
isFavorite = !isFavorite;
}
@@ -134,15 +256,16 @@
{
if (detail is null) return;
if (isBookmarked)
await Api.RemoveBookmarkAsync(detail.Id, deviceId);
await Api.RemoveAyahBookmarkAsync(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId);
else
await Api.AddBookmarkAsync(new ToggleEpisodeRequest(detail.Id, deviceId));
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.Id, position, deviceId));
await Api.SaveProgressAsync(new UpsertProgressRequest(
detail.TafsirId, detail.SurahNumber, detail.AyahNumber, position, deviceId));
}
}