Polish ayah UX with fixed player dock and progressive skeletons.
build-and-deploy / deploy (push) Successful in 4m45s
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:
@@ -2,35 +2,38 @@
|
|||||||
@inject ApiOptions ApiOptions
|
@inject ApiOptions ApiOptions
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<div class="audio-player" @ref="root">
|
<div class="audio-player dock-player" @ref="root" dir="rtl">
|
||||||
<div class="player-title">@Title</div>
|
<div class="dock-top">
|
||||||
<div class="player-actions">
|
<button type="button" class="dock-title" @onclick="OnTitleClick">@Title</button>
|
||||||
@if (ShowFavorite)
|
<div class="dock-actions">
|
||||||
{
|
@if (ShowFavorite)
|
||||||
<button type="button" class="chip @(IsFavorite ? "on" : null)" @onclick="OnFavoriteToggle">علاقه</button>
|
{
|
||||||
}
|
<button type="button" class="icon-chip @(IsFavorite ? "on" : null)" title="علاقه"
|
||||||
@if (ShowBookmark)
|
@onclick="OnFavoriteToggle" aria-label="علاقه">♥</button>
|
||||||
{
|
}
|
||||||
<button type="button" class="chip @(IsBookmarked ? "on" : null)" @onclick="OnBookmarkToggle">نشان</button>
|
@if (ShowBookmark)
|
||||||
}
|
{
|
||||||
|
<button type="button" class="icon-chip @(IsBookmarked ? "on" : null)" title="نشان"
|
||||||
|
@onclick="OnBookmarkToggle" aria-label="نشان">⚑</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(AudioUrl))
|
@if (!string.IsNullOrWhiteSpace(AudioUrl))
|
||||||
{
|
{
|
||||||
<audio @ref="audioEl" preload="metadata" src="@AudioSrc"></audio>
|
<audio @ref="audioEl" preload="metadata" src="@AudioSrc"></audio>
|
||||||
|
<div class="player-controls rtl-controls">
|
||||||
<div class="player-controls">
|
<div class="time">@Format(current) / @Format(duration)</div>
|
||||||
<button type="button" class="play-btn" @onclick="TogglePlay">
|
|
||||||
@(playing ? "❚❚" : "▶")
|
|
||||||
</button>
|
|
||||||
<input class="seek" type="range" min="0" max="@duration.ToString("0.###")" step="0.1"
|
<input class="seek" type="range" min="0" max="@duration.ToString("0.###")" step="0.1"
|
||||||
value="@current.ToString("0.###")" @oninput="OnSeek" />
|
value="@current.ToString("0.###")" @oninput="OnSeek" />
|
||||||
<div class="time">@Format(current) / @Format(duration)</div>
|
<button type="button" class="play-btn" @onclick="TogglePlay" aria-label="پخش">
|
||||||
|
@(playing ? "❚❚" : "▶")
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<p class="muted">صوت این آیه در دسترس نیست</p>
|
<p class="muted dock-empty">صوت این آیه در دسترس نیست</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@
|
|||||||
[Parameter] public EventCallback OnFavoriteToggle { get; set; }
|
[Parameter] public EventCallback OnFavoriteToggle { get; set; }
|
||||||
[Parameter] public EventCallback OnBookmarkToggle { get; set; }
|
[Parameter] public EventCallback OnBookmarkToggle { get; set; }
|
||||||
[Parameter] public EventCallback<double> OnProgress { get; set; }
|
[Parameter] public EventCallback<double> OnProgress { get; set; }
|
||||||
|
[Parameter] public EventCallback OnTitleClick { get; set; }
|
||||||
|
|
||||||
private ElementReference audioEl;
|
private ElementReference audioEl;
|
||||||
private ElementReference root;
|
private ElementReference root;
|
||||||
@@ -54,6 +58,7 @@
|
|||||||
private double current;
|
private double current;
|
||||||
private double duration;
|
private double duration;
|
||||||
private string boundKey = "";
|
private string boundKey = "";
|
||||||
|
private bool bound;
|
||||||
|
|
||||||
private string AudioSrc => ResolveAudioUrl(AudioUrl ?? "");
|
private string AudioSrc => ResolveAudioUrl(AudioUrl ?? "");
|
||||||
|
|
||||||
@@ -61,10 +66,11 @@
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(AudioUrl)) return;
|
if (string.IsNullOrWhiteSpace(AudioUrl)) return;
|
||||||
|
|
||||||
if (firstRender)
|
if (!bound)
|
||||||
{
|
{
|
||||||
self = DotNetObjectReference.Create(this);
|
self = DotNetObjectReference.Create(this);
|
||||||
await Js.InvokeVoidAsync("tqAudio.bind", audioEl, self);
|
await Js.InvokeVoidAsync("tqAudio.bind", audioEl, self);
|
||||||
|
bound = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var key = string.IsNullOrWhiteSpace(AudioKey) ? AudioSrc : AudioKey;
|
var key = string.IsNullOrWhiteSpace(AudioKey) ? AudioSrc : AudioKey;
|
||||||
@@ -131,7 +137,8 @@
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Js.InvokeVoidAsync("tqAudio.unbind", audioEl);
|
if (bound)
|
||||||
|
await Js.InvokeVoidAsync("tqAudio.unbind", audioEl);
|
||||||
}
|
}
|
||||||
catch { /* ignore */ }
|
catch { /* ignore */ }
|
||||||
self?.Dispose();
|
self?.Dispose();
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
@inject PlayerSessionService Session
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
@if (Session.HasTrack)
|
||||||
|
{
|
||||||
|
<div class="player-dock">
|
||||||
|
<AudioPlayer @key="@($"{Session.SurahNumber}:{Session.AyahNumber}:{Session.AudioUrl}")"
|
||||||
|
Title="@Session.DisplayTitle"
|
||||||
|
AudioUrl="@Session.AudioUrl"
|
||||||
|
AudioKey="@($"{Session.SurahNumber}:{Session.AyahNumber}")"
|
||||||
|
InitialPosition="Session.PositionSeconds"
|
||||||
|
IsFavorite="Session.IsFavorite"
|
||||||
|
IsBookmarked="Session.IsBookmarked"
|
||||||
|
OnFavoriteToggle="Session.ToggleFavoriteAsync"
|
||||||
|
OnBookmarkToggle="Session.ToggleBookmarkAsync"
|
||||||
|
OnProgress="OnProgress"
|
||||||
|
OnTitleClick="GoToTrack" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override void OnInitialized() => Session.Changed += OnChanged;
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender) return;
|
||||||
|
await Session.EnsureLoadedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnProgress(double position) => await Session.ReportPositionAsync(position);
|
||||||
|
|
||||||
|
private void GoToTrack()
|
||||||
|
{
|
||||||
|
if (Session.HasTrack)
|
||||||
|
Nav.NavigateTo(Session.ResumeHref);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
public void Dispose() => Session.Changed -= OnChanged;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@if (Variant == "lines")
|
||||||
|
{
|
||||||
|
<div class="skeleton-stack" aria-hidden="true">
|
||||||
|
@for (var i = 0; i < Lines; i++)
|
||||||
|
{
|
||||||
|
<div class="skeleton-line" style="@($"width:{(i % 2 == 0 ? 92 : 70)}%")"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Variant == "cards")
|
||||||
|
{
|
||||||
|
<div class="skeleton-stack" aria-hidden="true">
|
||||||
|
@for (var i = 0; i < Lines; i++)
|
||||||
|
{
|
||||||
|
<div class="skeleton-card"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="skeleton-block" aria-hidden="true"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Variant { get; set; } = "block";
|
||||||
|
[Parameter] public int Lines { get; set; } = 4;
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@inject AuthStateService Auth
|
@inject AuthStateService Auth
|
||||||
|
|
||||||
<div class="app-shell @(sidebarOpen ? "sidebar-open" : null)">
|
<div class="app-shell @(sidebarOpen ? "sidebar-open" : null) @(hasDock ? "has-player-dock" : null)">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<button class="icon-btn" type="button" @onclick="ToggleSidebar" aria-label="منو">
|
<button class="icon-btn" type="button" @onclick="ToggleSidebar" aria-label="منو">
|
||||||
<span class="burger"></span>
|
<span class="burger"></span>
|
||||||
@@ -20,23 +20,43 @@
|
|||||||
<main class="content">
|
<main class="content">
|
||||||
@Body
|
@Body
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<PlayerDock />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
[Inject] private PlayerSessionService Session { get; set; } = default!;
|
||||||
private bool sidebarOpen;
|
private bool sidebarOpen;
|
||||||
|
private bool hasDock;
|
||||||
|
|
||||||
protected override void OnInitialized() => Auth.Changed += OnAuthChanged;
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Auth.Changed += OnAuthChanged;
|
||||||
|
Session.Changed += OnSessionChanged;
|
||||||
|
hasDock = Session.HasTrack;
|
||||||
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (!firstRender) return;
|
if (!firstRender) return;
|
||||||
await Auth.RefreshAsync();
|
await Auth.RefreshAsync();
|
||||||
|
await Session.EnsureLoadedAsync();
|
||||||
|
hasDock = Session.HasTrack;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnAuthChanged() => InvokeAsync(StateHasChanged);
|
private void OnAuthChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
private void OnSessionChanged()
|
||||||
|
{
|
||||||
|
hasDock = Session.HasTrack;
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
private void ToggleSidebar() => sidebarOpen = !sidebarOpen;
|
private void ToggleSidebar() => sidebarOpen = !sidebarOpen;
|
||||||
private void CloseSidebar() => sidebarOpen = false;
|
private void CloseSidebar() => sidebarOpen = false;
|
||||||
|
|
||||||
public void Dispose() => Auth.Changed -= OnAuthChanged;
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Auth.Changed -= OnAuthChanged;
|
||||||
|
Session.Changed -= OnSessionChanged;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<NavLink class="side-item" href="/tafsir/ghatreh-chekani" @onclick="Navigate">فهرست سورهها</NavLink>
|
||||||
<NavLink class="side-item" href="/favorites" @onclick="Navigate">علاقهمندیها</NavLink>
|
<NavLink class="side-item" href="/favorites" @onclick="Navigate">علاقهمندیها</NavLink>
|
||||||
<NavLink class="side-item" href="/bookmarks" @onclick="Navigate">نشانشدهها</NavLink>
|
<NavLink class="side-item" href="/bookmarks" @onclick="Navigate">نشانشدهها</NavLink>
|
||||||
<NavLink class="side-item" href="/about" @onclick="Navigate">درباره ما</NavLink>
|
<NavLink class="side-item" href="/about" @onclick="Navigate">درباره ما</NavLink>
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@inject TafsirApiClient Api
|
@inject TafsirApiClient Api
|
||||||
|
@inject PlayerSessionService Player
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
<section class="home">
|
<section class="home">
|
||||||
<h1 class="sr-only">تفسیر قرآن</h1>
|
<h1 class="sr-only">تفسیر قرآن</h1>
|
||||||
|
|
||||||
|
@if (Player.HasTrack)
|
||||||
|
{
|
||||||
|
<button type="button" class="resume-btn" @onclick="Resume">
|
||||||
|
<span class="resume-label">ادامه از آخرین مراجعه</span>
|
||||||
|
<span class="resume-meta">@Player.DisplayTitle</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
@if (loading)
|
@if (loading)
|
||||||
{
|
{
|
||||||
<p class="muted">در حال بارگذاری...</p>
|
<SkeletonBlock Variant="cards" Lines="2" />
|
||||||
}
|
}
|
||||||
else if (tafsirs.Count == 0)
|
else if (tafsirs.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -33,11 +44,14 @@
|
|||||||
private List<TafsirDto> tafsirs = [];
|
private List<TafsirDto> tafsirs = [];
|
||||||
private bool loading = true;
|
private bool loading = true;
|
||||||
|
|
||||||
|
protected override void OnInitialized() => Player.Changed += OnPlayerChanged;
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (!firstRender) return;
|
if (!firstRender) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
await Player.EnsureLoadedAsync();
|
||||||
tafsirs = (await Api.GetTafsirsAsync()).ToList();
|
tafsirs = (await Api.GetTafsirsAsync()).ToList();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -50,4 +64,10 @@
|
|||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void Resume() => Nav.NavigateTo(Player.ResumeHref);
|
||||||
|
|
||||||
|
private void OnPlayerChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
public void Dispose() => Player.Changed -= OnPlayerChanged;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,111 +4,128 @@
|
|||||||
@inject TafsirApiClient Api
|
@inject TafsirApiClient Api
|
||||||
@inject IDeviceIdStore DeviceStore
|
@inject IDeviceIdStore DeviceStore
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
@inject PlayerSessionService Player
|
||||||
|
|
||||||
<section class="tafsir-page">
|
<section class="tafsir-page">
|
||||||
<header class="page-header">
|
<header class="page-header page-header-row">
|
||||||
<h1>@title</h1>
|
@if (loadingMeta)
|
||||||
<p class="muted">@author</p>
|
|
||||||
@if (view == View.Ayahs || view == View.Detail)
|
|
||||||
{
|
{
|
||||||
<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>
|
</header>
|
||||||
|
|
||||||
@if (loading)
|
@if (view == View.Surahs)
|
||||||
{
|
{
|
||||||
<p class="muted">در حال بارگذاری...</p>
|
@if (loadingSurahs)
|
||||||
}
|
{
|
||||||
else if (view == View.Surahs)
|
<SkeletonBlock Variant="cards" Lines="8" />
|
||||||
{
|
}
|
||||||
<div class="surah-grid">
|
else
|
||||||
@foreach (var s in surahs)
|
{
|
||||||
{
|
<div class="surah-grid">
|
||||||
<div class="surah-card">
|
@foreach (var s in surahs)
|
||||||
<button type="button" class="surah-main" @onclick="() => OpenSurah(s.Number)">
|
{
|
||||||
<span class="surah-num">@s.Number</span>
|
<div class="surah-card">
|
||||||
<span class="surah-names">
|
<button type="button" class="surah-main" @onclick="() => OpenSurah(s.Number)">
|
||||||
<strong>@s.NameFa</strong>
|
<span class="surah-num">@s.Number</span>
|
||||||
<span class="muted">@s.NameAr</span>
|
<span class="surah-names">
|
||||||
</span>
|
<strong>@s.NameFa</strong>
|
||||||
<span class="muted">@s.AyahCount آیه</span>
|
<span class="muted">@s.NameAr</span>
|
||||||
</button>
|
</span>
|
||||||
<button type="button" class="chip @(s.IsBookmarked ? "on" : null)"
|
<span class="muted">@s.AyahCount آیه</span>
|
||||||
@onclick="() => ToggleSurahBookmark(s)">نشان</button>
|
</button>
|
||||||
</div>
|
<button type="button" class="chip @(s.IsBookmarked ? "on" : null)"
|
||||||
}
|
@onclick="() => ToggleSurahBookmark(s)">نشان</button>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (view == View.Ayahs)
|
else if (view == View.Ayahs)
|
||||||
{
|
{
|
||||||
<h2 class="section-title">سوره @selectedSurah?.NameFa</h2>
|
<h2 class="section-title">سوره @(selectedSurah?.NameFa ?? "…")</h2>
|
||||||
<div class="ayah-list">
|
@if (loadingAyahs)
|
||||||
@foreach (var a in ayahs)
|
{
|
||||||
{
|
<SkeletonBlock Variant="cards" Lines="10" />
|
||||||
<button type="button" class="ayah-row" @onclick="() => OpenAyah(a.AyahNumber)">
|
}
|
||||||
<span class="ayah-num">@a.AyahNumber</span>
|
else
|
||||||
<span class="ayah-preview">@(a.TextFaPreview ?? "آیه " + a.AyahNumber)</span>
|
{
|
||||||
<span class="ayah-flags">
|
<div class="ayah-list">
|
||||||
@if (a.IsFavorite) { <span class="dot fav" title="علاقه"></span> }
|
@foreach (var a in ayahs)
|
||||||
@if (a.IsBookmarked) { <span class="dot mark" title="نشان"></span> }
|
{
|
||||||
@if (a.HasAudio) { <span class="dot audio" title="صوت"></span> }
|
<button type="button" class="ayah-row" @onclick="() => OpenAyah(a.AyahNumber)">
|
||||||
</span>
|
<span class="ayah-num">@a.AyahNumber</span>
|
||||||
</button>
|
<span class="ayah-preview">@(a.TextFaPreview ?? "آیه " + a.AyahNumber)</span>
|
||||||
}
|
<span class="ayah-flags">
|
||||||
</div>
|
@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-detail">
|
||||||
<div class="ayah-nav">
|
<div class="ayah-nav">
|
||||||
<button type="button" class="chip" disabled="@(detail.PrevAyah is null)"
|
<button type="button" class="chip" disabled="@(detail?.PrevAyah is null)"
|
||||||
@onclick="() => OpenAyah(detail.PrevAyah!.Value)">آیه قبل</button>
|
@onclick="() => GoPrevAyah()">آیه قبل</button>
|
||||||
<button type="button" class="chip" @onclick="() => OpenSurah(detail.SurahNumber)">فهرست آیات</button>
|
<button type="button" class="chip" @onclick="() => OpenSurah(SurahNumber ?? 1)">فهرست آیات</button>
|
||||||
<button type="button" class="chip" disabled="@(detail.NextAyah is null)"
|
<button type="button" class="chip" disabled="@(detail?.NextAyah is null)"
|
||||||
@onclick="() => OpenAyah(detail.NextAyah!.Value)">آیه بعد</button>
|
@onclick="() => GoNextAyah()">آیه بعد</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ayah-block">
|
@if (loadingDetail || detail is null)
|
||||||
<p class="ayah-ar">@detail.TextAr</p>
|
{
|
||||||
@if (!string.IsNullOrWhiteSpace(detail.TextFa))
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -124,16 +141,16 @@
|
|||||||
private string title = "تفسیر";
|
private string title = "تفسیر";
|
||||||
private string author = "";
|
private string author = "";
|
||||||
private Guid tafsirId;
|
private Guid tafsirId;
|
||||||
private bool loading = true;
|
private bool loadingMeta = true;
|
||||||
|
private bool loadingSurahs;
|
||||||
|
private bool loadingAyahs;
|
||||||
|
private bool loadingDetail;
|
||||||
private string? routeKey;
|
private string? routeKey;
|
||||||
private View view = View.Surahs;
|
private View view = View.Surahs;
|
||||||
private List<SurahListItemDto> surahs = [];
|
private List<SurahListItemDto> surahs = [];
|
||||||
private List<AyahListItemDto> ayahs = [];
|
private List<AyahListItemDto> ayahs = [];
|
||||||
private SurahListItemDto? selectedSurah;
|
private SurahListItemDto? selectedSurah;
|
||||||
private AyahDetailDto? detail;
|
private AyahDetailDto? detail;
|
||||||
private double initialPosition;
|
|
||||||
private bool isFavorite;
|
|
||||||
private bool isBookmarked;
|
|
||||||
private string deviceId = "";
|
private string deviceId = "";
|
||||||
private bool ready;
|
private bool ready;
|
||||||
|
|
||||||
@@ -157,7 +174,15 @@
|
|||||||
if (routeKey == key) return;
|
if (routeKey == key) return;
|
||||||
routeKey = key;
|
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);
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -166,37 +191,39 @@
|
|||||||
deviceId = await DeviceStore.GetOrCreateAsync();
|
deviceId = await DeviceStore.GetOrCreateAsync();
|
||||||
|
|
||||||
var tafsirs = await Api.GetTafsirsAsync();
|
var tafsirs = await Api.GetTafsirsAsync();
|
||||||
var tafsir = tafsirs.FirstOrDefault(t => t.Slug == slug);
|
var tafsir = tafsirs.FirstOrDefault(t => t.Slug == Slug);
|
||||||
title = tafsir?.Title ?? "تفسیر";
|
title = tafsir?.Title ?? "تفسیر";
|
||||||
author = tafsir?.Author ?? "";
|
author = tafsir?.Author ?? "";
|
||||||
tafsirId = tafsir?.Id ?? Guid.Empty;
|
tafsirId = tafsir?.Id ?? Guid.Empty;
|
||||||
|
loadingMeta = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
if (AyahNumber is int a && SurahNumber is int s)
|
if (view == View.Detail && SurahNumber is int s && AyahNumber is int a)
|
||||||
{
|
|
||||||
view = View.Detail;
|
|
||||||
await LoadDetail(s, a);
|
await LoadDetail(s, a);
|
||||||
}
|
else if (view == View.Ayahs && SurahNumber is int sn)
|
||||||
else if (SurahNumber is int sn)
|
|
||||||
{
|
|
||||||
view = View.Ayahs;
|
|
||||||
await LoadAyahs(sn);
|
await LoadAyahs(sn);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
await LoadSurahs();
|
||||||
view = View.Surahs;
|
|
||||||
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
loading = false;
|
loadingMeta = false;
|
||||||
|
loadingSurahs = false;
|
||||||
|
loadingAyahs = false;
|
||||||
|
loadingDetail = false;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string slug => Slug;
|
private void GoBack()
|
||||||
|
{
|
||||||
private void BackToSurahs() => Nav.NavigateTo($"/tafsir/{Slug}");
|
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}");
|
private void OpenSurah(int n) => Nav.NavigateTo($"/tafsir/{Slug}/surah/{n}");
|
||||||
|
|
||||||
@@ -206,30 +233,52 @@
|
|||||||
Nav.NavigateTo($"/tafsir/{Slug}/surah/{s}/ayah/{n}");
|
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)
|
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);
|
selectedSurah = surahs.FirstOrDefault(x => x.Number == surahNumber);
|
||||||
ayahs = (await Api.GetAyahsAsync(Slug, surahNumber, deviceId)).ToList();
|
ayahs = (await Api.GetAyahsAsync(Slug, surahNumber, deviceId)).ToList();
|
||||||
|
loadingAyahs = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadDetail(int surahNumber, int ayahNumber)
|
private async Task LoadDetail(int surahNumber, int ayahNumber)
|
||||||
{
|
{
|
||||||
|
loadingDetail = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
detail = await Api.GetAyahAsync(Slug, surahNumber, ayahNumber);
|
detail = await Api.GetAyahAsync(Slug, surahNumber, ayahNumber);
|
||||||
|
loadingDetail = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
if (detail is null) return;
|
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;
|
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)
|
private async Task ToggleSurahBookmark(SurahListItemDto s)
|
||||||
@@ -241,31 +290,4 @@
|
|||||||
await Api.AddSurahBookmarkAsync(new ToggleSurahBookmarkRequest(tafsirId, s.Number, deviceId));
|
await Api.AddSurahBookmarkAsync(new ToggleSurahBookmarkRequest(tafsirId, s.Number, deviceId));
|
||||||
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId)).ToList();
|
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
using TafsirQuran.Shared.Dtos;
|
||||||
|
using TafsirQuran.Shared.Services;
|
||||||
|
|
||||||
|
namespace TafsirQuran.UI.Services;
|
||||||
|
|
||||||
|
public sealed class PlayerSessionService
|
||||||
|
{
|
||||||
|
private readonly TafsirApiClient _api;
|
||||||
|
private readonly IDeviceIdStore _devices;
|
||||||
|
private bool _loaded;
|
||||||
|
private string _deviceId = "";
|
||||||
|
|
||||||
|
public PlayerSessionService(TafsirApiClient api, IDeviceIdStore devices)
|
||||||
|
{
|
||||||
|
_api = api;
|
||||||
|
_devices = devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action? Changed;
|
||||||
|
|
||||||
|
public bool HasTrack => SurahNumber > 0 && AyahNumber > 0 && !string.IsNullOrWhiteSpace(TafsirSlug);
|
||||||
|
|
||||||
|
public Guid TafsirId { get; private set; }
|
||||||
|
public string TafsirSlug { get; private set; } = "ghatreh-chekani";
|
||||||
|
public int SurahNumber { get; private set; }
|
||||||
|
public int AyahNumber { get; private set; }
|
||||||
|
public string SurahNameFa { get; private set; } = "";
|
||||||
|
public string? AudioUrl { get; private set; }
|
||||||
|
public double PositionSeconds { get; private set; }
|
||||||
|
public bool IsFavorite { get; private set; }
|
||||||
|
public bool IsBookmarked { get; private set; }
|
||||||
|
|
||||||
|
public string DisplayTitle =>
|
||||||
|
string.IsNullOrWhiteSpace(SurahNameFa)
|
||||||
|
? $"آیه {AyahNumber}"
|
||||||
|
: $"{SurahNameFa} - آیه {AyahNumber}";
|
||||||
|
|
||||||
|
public string ResumeHref =>
|
||||||
|
HasTrack ? $"/tafsir/{TafsirSlug}/surah/{SurahNumber}/ayah/{AyahNumber}" : "/";
|
||||||
|
|
||||||
|
public async Task EnsureLoadedAsync()
|
||||||
|
{
|
||||||
|
if (_loaded) return;
|
||||||
|
_deviceId = await _devices.GetOrCreateAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var progress = await _api.GetProgressAsync(_deviceId);
|
||||||
|
if (progress is not null)
|
||||||
|
{
|
||||||
|
TafsirId = progress.TafsirId;
|
||||||
|
SurahNumber = progress.SurahNumber;
|
||||||
|
AyahNumber = progress.AyahNumber;
|
||||||
|
SurahNameFa = progress.SurahNameFa ?? "";
|
||||||
|
PositionSeconds = progress.PositionSeconds;
|
||||||
|
|
||||||
|
var tafsirs = await _api.GetTafsirsAsync();
|
||||||
|
var tafsir = tafsirs.FirstOrDefault(t => t.Id == progress.TafsirId);
|
||||||
|
TafsirSlug = tafsir?.Slug ?? "ghatreh-chekani";
|
||||||
|
|
||||||
|
var detail = await _api.GetAyahAsync(TafsirSlug, SurahNumber, AyahNumber);
|
||||||
|
if (detail is not null)
|
||||||
|
{
|
||||||
|
AudioUrl = detail.AudioUrl;
|
||||||
|
SurahNameFa = detail.SurahNameFa;
|
||||||
|
await SyncFlagsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignore bootstrap failures */
|
||||||
|
}
|
||||||
|
|
||||||
|
_loaded = true;
|
||||||
|
Notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetTrackAsync(AyahDetailDto detail, double position = 0)
|
||||||
|
{
|
||||||
|
await EnsureDeviceAsync();
|
||||||
|
TafsirId = detail.TafsirId;
|
||||||
|
TafsirSlug = detail.TafsirSlug;
|
||||||
|
SurahNumber = detail.SurahNumber;
|
||||||
|
AyahNumber = detail.AyahNumber;
|
||||||
|
SurahNameFa = detail.SurahNameFa;
|
||||||
|
AudioUrl = detail.AudioUrl;
|
||||||
|
PositionSeconds = position;
|
||||||
|
await SyncFlagsAsync();
|
||||||
|
Notify();
|
||||||
|
await PersistAsync(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReportPositionAsync(double position)
|
||||||
|
{
|
||||||
|
if (!HasTrack) return;
|
||||||
|
PositionSeconds = position;
|
||||||
|
await PersistAsync(position);
|
||||||
|
Notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ToggleFavoriteAsync()
|
||||||
|
{
|
||||||
|
if (!HasTrack) return;
|
||||||
|
await EnsureDeviceAsync();
|
||||||
|
if (IsFavorite)
|
||||||
|
await _api.RemoveFavoriteAsync(TafsirId, SurahNumber, AyahNumber, _deviceId);
|
||||||
|
else
|
||||||
|
await _api.AddFavoriteAsync(new ToggleAyahRequest(TafsirId, SurahNumber, AyahNumber, _deviceId));
|
||||||
|
IsFavorite = !IsFavorite;
|
||||||
|
Notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ToggleBookmarkAsync()
|
||||||
|
{
|
||||||
|
if (!HasTrack) return;
|
||||||
|
await EnsureDeviceAsync();
|
||||||
|
if (IsBookmarked)
|
||||||
|
await _api.RemoveAyahBookmarkAsync(TafsirId, SurahNumber, AyahNumber, _deviceId);
|
||||||
|
else
|
||||||
|
await _api.AddAyahBookmarkAsync(new ToggleAyahRequest(TafsirId, SurahNumber, AyahNumber, _deviceId));
|
||||||
|
IsBookmarked = !IsBookmarked;
|
||||||
|
Notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SyncFlagsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var favorites = await _api.GetFavoritesAsync(_deviceId);
|
||||||
|
var bookmarks = await _api.GetAyahBookmarksAsync(_deviceId);
|
||||||
|
IsFavorite = favorites.Any(f =>
|
||||||
|
f.TafsirId == TafsirId && f.SurahNumber == SurahNumber && f.AyahNumber == AyahNumber);
|
||||||
|
IsBookmarked = bookmarks.Any(b =>
|
||||||
|
b.TafsirId == TafsirId && b.SurahNumber == SurahNumber && b.AyahNumber == AyahNumber);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PersistAsync(double position)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EnsureDeviceAsync();
|
||||||
|
await _api.SaveProgressAsync(new UpsertProgressRequest(
|
||||||
|
TafsirId, SurahNumber, AyahNumber, position, _deviceId));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureDeviceAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_deviceId))
|
||||||
|
_deviceId = await _devices.GetOrCreateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Notify() => Changed?.Invoke();
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ public static class UiServiceCollectionExtensions
|
|||||||
services.AddScoped<IDeviceIdStore, BrowserDeviceIdStore>();
|
services.AddScoped<IDeviceIdStore, BrowserDeviceIdStore>();
|
||||||
services.AddScoped<IAuthTokenStore, BrowserAuthTokenStore>();
|
services.AddScoped<IAuthTokenStore, BrowserAuthTokenStore>();
|
||||||
services.AddScoped<AuthStateService>();
|
services.AddScoped<AuthStateService>();
|
||||||
|
services.AddScoped<PlayerSessionService>();
|
||||||
services.AddScoped(sp =>
|
services.AddScoped(sp =>
|
||||||
{
|
{
|
||||||
var http = new HttpClient { BaseAddress = new Uri(normalized) };
|
var http = new HttpClient { BaseAddress = new Uri(normalized) };
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ button, input { font: inherit; }
|
|||||||
.user-chip { padding: 0.5rem 0.9rem; color: #c7d8ce; font-size: 0.9rem; }
|
.user-chip { padding: 0.5rem 0.9rem; color: #c7d8ce; font-size: 0.9rem; }
|
||||||
|
|
||||||
.content { padding: 1.25rem 1rem 2.5rem; max-width: 980px; margin: 0 auto; }
|
.content { padding: 1.25rem 1rem 2.5rem; max-width: 980px; margin: 0 auto; }
|
||||||
|
.app-shell.has-player-dock .content { padding-bottom: 8.5rem; }
|
||||||
|
|
||||||
.home { padding-top: 1.5rem; }
|
.home { padding-top: 1.5rem; }
|
||||||
.tile-grid { display: grid; gap: 1rem; }
|
.tile-grid { display: grid; gap: 1rem; }
|
||||||
@@ -181,11 +182,18 @@ button, input { font: inherit; }
|
|||||||
.ayah-block { margin-bottom: 1rem; }
|
.ayah-block { margin-bottom: 1rem; }
|
||||||
.ayah-ar {
|
.ayah-ar {
|
||||||
font-family: var(--font-ar);
|
font-family: var(--font-ar);
|
||||||
font-size: clamp(1.4rem, 3vw, 1.9rem);
|
font-size: clamp(1.75rem, 4.2vw, 2.35rem);
|
||||||
line-height: 2;
|
line-height: 2.05;
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.65rem;
|
||||||
|
color: #14261e;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.ayah-fa {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.05rem, 2.6vw, 1.28rem);
|
||||||
|
line-height: 1.95;
|
||||||
|
color: #3a4a42;
|
||||||
}
|
}
|
||||||
.ayah-fa { margin: 0; color: var(--muted); }
|
|
||||||
|
|
||||||
.audio-player { display: flex; flex-direction: column; gap: 0.85rem; }
|
.audio-player { display: flex; flex-direction: column; gap: 0.85rem; }
|
||||||
.player-title { font-weight: 600; }
|
.player-title { font-weight: 600; }
|
||||||
@@ -272,3 +280,81 @@ button, input { font: inherit; }
|
|||||||
padding: 0.9rem 1rem; border-radius: 14px; background: var(--surface); border: 1px solid var(--line);
|
padding: 0.9rem 1rem; border-radius: 14px; background: var(--surface); border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.chip:disabled { opacity: 0.45; cursor: default; }
|
.chip:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
|
||||||
|
.page-header-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.page-title-inline {
|
||||||
|
margin: 0; font-size: clamp(1.15rem, 3.5vw, 1.45rem); font-weight: 700;
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem;
|
||||||
|
}
|
||||||
|
.title-author { font-weight: 500; color: var(--muted); font-size: 0.92em; }
|
||||||
|
.back-btn {
|
||||||
|
flex-shrink: 0; width: 42px; height: 42px; border-radius: 12px;
|
||||||
|
border: 1px solid var(--line); background: #fff; cursor: pointer;
|
||||||
|
font-size: 1.25rem; line-height: 1; color: var(--brand-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-stack { display: grid; gap: 0.65rem; margin: 0.5rem 0 1rem; }
|
||||||
|
.skeleton-line, .skeleton-card, .skeleton-block {
|
||||||
|
background: linear-gradient(90deg, rgba(28,43,36,0.07), rgba(28,43,36,0.14), rgba(28,43,36,0.07));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: skeleton-shine 1.2s ease-in-out infinite;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.skeleton-line { height: 14px; }
|
||||||
|
.skeleton-card { height: 64px; border-radius: 14px; }
|
||||||
|
.skeleton-block { height: 120px; }
|
||||||
|
@keyframes skeleton-shine {
|
||||||
|
0% { background-position: 100% 0; }
|
||||||
|
100% { background-position: -100% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-dock {
|
||||||
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
|
||||||
|
padding: 0.65rem 0.85rem calc(0.65rem + env(safe-area-inset-bottom));
|
||||||
|
background: rgba(243, 239, 230, 0.94);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
box-shadow: 0 -10px 28px rgba(28, 43, 36, 0.08);
|
||||||
|
}
|
||||||
|
.dock-player {
|
||||||
|
max-width: 980px; margin: 0 auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 0.75rem 0.9rem 0.85rem;
|
||||||
|
direction: rtl;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.dock-top {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
.dock-title {
|
||||||
|
border: 0; background: transparent; padding: 0; cursor: pointer;
|
||||||
|
font-weight: 700; font-size: 0.98rem; color: var(--ink); text-align: right;
|
||||||
|
}
|
||||||
|
.dock-actions { display: flex; gap: 0.35rem; flex-shrink: 0; }
|
||||||
|
.icon-chip {
|
||||||
|
width: 36px; height: 36px; border-radius: 10px;
|
||||||
|
border: 1px solid var(--line); background: #fff; cursor: pointer;
|
||||||
|
color: var(--muted); font-size: 1rem;
|
||||||
|
}
|
||||||
|
.icon-chip.on { background: var(--brand); color: #fff; border-color: var(--brand); }
|
||||||
|
.rtl-controls {
|
||||||
|
display: grid; grid-template-columns: auto 1fr auto; gap: 0.65rem; align-items: center;
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
.dock-empty { margin: 0; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.resume-btn {
|
||||||
|
width: 100%; margin: 0 0 1rem; padding: 1rem 1.1rem;
|
||||||
|
border: 1px solid rgba(31,107,74,0.35); border-radius: 18px;
|
||||||
|
background: linear-gradient(135deg, rgba(31,107,74,0.12), rgba(31,107,74,0.04));
|
||||||
|
cursor: pointer; text-align: right;
|
||||||
|
display: flex; flex-direction: column; gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.resume-label { font-weight: 700; color: var(--brand-deep); }
|
||||||
|
.resume-meta { color: var(--muted); font-size: 0.9rem; }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Cache static WASM assets only — never HTML navigations.
|
// Cache static WASM assets only — never HTML navigations.
|
||||||
const CACHE = 'tafsirquran-wasm-v2';
|
const CACHE = 'tafsirquran-wasm-v3';
|
||||||
|
|
||||||
self.addEventListener('install', event => event.waitUntil(self.skipWaiting()));
|
self.addEventListener('install', event => event.waitUntil(self.skipWaiting()));
|
||||||
self.addEventListener('activate', event => event.waitUntil(
|
self.addEventListener('activate', event => event.waitUntil(
|
||||||
|
|||||||
Reference in New Issue
Block a user