Initial TafsirQuran app with Gitea CI/CD deploy to k3s.
build-and-deploy / deploy (push) Successful in 10m50s
build-and-deploy / deploy (push) Successful in 10m50s
Blazor UI + ASP.NET API + Postgres; push to main builds images and rolls out namespace tafsirquran.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
@inject IJSRuntime Js
|
||||
@inject ApiOptions ApiOptions
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="audio-player" @ref="root">
|
||||
<div class="player-title">@Episode.Title</div>
|
||||
<div class="player-actions">
|
||||
<button type="button" class="chip @(IsFavorite ? "on" : null)" @onclick="OnFavoriteToggle">علاقه</button>
|
||||
<button type="button" class="chip @(IsBookmarked ? "on" : null)" @onclick="OnBookmarkToggle">نشان</button>
|
||||
</div>
|
||||
|
||||
<audio @ref="audioEl" preload="metadata" src="@AudioSrc"></audio>
|
||||
|
||||
<div class="player-controls">
|
||||
<button type="button" class="play-btn" @onclick="TogglePlay">
|
||||
@(playing ? "❚❚" : "▶")
|
||||
</button>
|
||||
<input class="seek" type="range" min="0" max="@duration.ToString("0.###")" step="0.1"
|
||||
value="@current.ToString("0.###")" @oninput="OnSeek" />
|
||||
<div class="time">@Format(current) / @Format(duration)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public EpisodeDetailDto Episode { get; set; } = default!;
|
||||
[Parameter] public double InitialPosition { get; set; }
|
||||
[Parameter] public bool IsFavorite { get; set; }
|
||||
[Parameter] public bool IsBookmarked { get; set; }
|
||||
[Parameter] public EventCallback OnFavoriteToggle { get; set; }
|
||||
[Parameter] public EventCallback OnBookmarkToggle { get; set; }
|
||||
[Parameter] public EventCallback<double> OnProgress { get; set; }
|
||||
|
||||
private ElementReference audioEl;
|
||||
private ElementReference root;
|
||||
private DotNetObjectReference<AudioPlayer>? self;
|
||||
private bool playing;
|
||||
private double current;
|
||||
private double duration;
|
||||
private Guid boundEpisodeId;
|
||||
|
||||
private string AudioSrc => ResolveAudioUrl(Episode.AudioUrl);
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
self = DotNetObjectReference.Create(this);
|
||||
await Js.InvokeVoidAsync("tqAudio.bind", audioEl, self);
|
||||
}
|
||||
|
||||
if (boundEpisodeId != Episode.Id)
|
||||
{
|
||||
boundEpisodeId = Episode.Id;
|
||||
playing = false;
|
||||
current = InitialPosition;
|
||||
await Js.InvokeVoidAsync("tqAudio.load", audioEl, AudioSrc, InitialPosition);
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveAudioUrl(string url)
|
||||
{
|
||||
if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
return url;
|
||||
return new Uri(new Uri(ApiOptions.BaseAddress), url.TrimStart('/')).ToString();
|
||||
}
|
||||
|
||||
private async Task TogglePlay()
|
||||
{
|
||||
playing = await Js.InvokeAsync<bool>("tqAudio.toggle", audioEl);
|
||||
}
|
||||
|
||||
private async Task OnSeek(ChangeEventArgs e)
|
||||
{
|
||||
if (double.TryParse(e.Value?.ToString(), out var v))
|
||||
{
|
||||
current = v;
|
||||
await Js.InvokeVoidAsync("tqAudio.seek", audioEl, v);
|
||||
await OnProgress.InvokeAsync(v);
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnTime(double time, double dur, bool isPlaying)
|
||||
{
|
||||
current = time;
|
||||
duration = dur > 0 ? dur : duration;
|
||||
playing = isPlaying;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
if (isPlaying && Math.Floor(time) % 5 == 0)
|
||||
await OnProgress.InvokeAsync(time);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnPaused(double time)
|
||||
{
|
||||
playing = false;
|
||||
current = time;
|
||||
await OnProgress.InvokeAsync(time);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private static string Format(double seconds)
|
||||
{
|
||||
if (seconds < 0 || double.IsNaN(seconds) || double.IsInfinity(seconds)) seconds = 0;
|
||||
var ts = TimeSpan.FromSeconds(seconds);
|
||||
return ts.TotalHours >= 1 ? ts.ToString(@"h\:mm\:ss") : ts.ToString(@"mm\:ss");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Js.InvokeVoidAsync("tqAudio.unbind", audioEl);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
self?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@inject AuthStateService Auth
|
||||
|
||||
<div class="app-shell @(sidebarOpen ? "sidebar-open" : null)">
|
||||
<header class="topbar">
|
||||
<button class="icon-btn" type="button" @onclick="ToggleSidebar" aria-label="منو">
|
||||
<span class="burger"></span>
|
||||
</button>
|
||||
<a class="brand" href="/">تفسیر قرآن</a>
|
||||
<div class="topbar-spacer"></div>
|
||||
</header>
|
||||
|
||||
<div class="backdrop" @onclick="CloseSidebar"></div>
|
||||
|
||||
<aside class="sidebar">
|
||||
<Sidebar OnNavigate="CloseSidebar" />
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool sidebarOpen;
|
||||
|
||||
protected override void OnInitialized() => Auth.Changed += OnAuthChanged;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
await Auth.RefreshAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnAuthChanged() => InvokeAsync(StateHasChanged);
|
||||
private void ToggleSidebar() => sidebarOpen = !sidebarOpen;
|
||||
private void CloseSidebar() => sidebarOpen = false;
|
||||
|
||||
public void Dispose() => Auth.Changed -= OnAuthChanged;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@inject AuthStateService Auth
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<nav class="side-nav">
|
||||
<div class="side-section">
|
||||
<button class="side-item cascade-toggle" type="button" @onclick="() => tafsirsOpen = !tafsirsOpen">
|
||||
<span>تفسیرها</span>
|
||||
<span class="chevron @(tafsirsOpen ? "open" : null)">▾</span>
|
||||
</button>
|
||||
@if (tafsirsOpen)
|
||||
{
|
||||
<div class="cascade">
|
||||
<NavLink class="side-item nested" href="/tafsir/ghatreh-chekani" @onclick="Navigate">
|
||||
تفسیر قطره چکانی
|
||||
</NavLink>
|
||||
</div>
|
||||
}
|
||||
|
||||
<NavLink class="side-item" href="/favorites" @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="/contact" @onclick="Navigate">تماس با ما</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="side-footer">
|
||||
@if (Auth.Current.IsAuthenticated)
|
||||
{
|
||||
<div class="user-chip">@Auth.Current.DisplayName</div>
|
||||
<button class="side-item danger" type="button" @onclick="Logout">خروج</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<NavLink class="side-item" href="/login" @onclick="Navigate">ورود / ثبتنام</NavLink>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@code {
|
||||
[Parameter] public EventCallback OnNavigate { get; set; }
|
||||
private bool tafsirsOpen = true;
|
||||
|
||||
private async Task Navigate() => await OnNavigate.InvokeAsync();
|
||||
|
||||
private async Task Logout()
|
||||
{
|
||||
await Auth.LogoutAsync();
|
||||
await OnNavigate.InvokeAsync();
|
||||
Nav.NavigateTo("/");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@page "/about"
|
||||
|
||||
<section class="prose-page">
|
||||
<h1>درباره ما</h1>
|
||||
<p>
|
||||
این اپلیکیشن برای شنیدن تفسیر قرآن کریم طراحی شده است.
|
||||
فاز نخست با تفسیر قطرهچکانی استاد قرائتی آغاز میشود و بهتدریج مفسران و منابع بیشتری افزوده خواهد شد.
|
||||
</p>
|
||||
</section>
|
||||
@@ -0,0 +1,45 @@
|
||||
@page "/bookmarks"
|
||||
@inject TafsirApiClient Api
|
||||
@inject IDeviceIdStore DeviceStore
|
||||
|
||||
<section class="list-page">
|
||||
<h1>نشانشدهها</h1>
|
||||
@if (loading)
|
||||
{
|
||||
<p class="muted">در حال بارگذاری...</p>
|
||||
}
|
||||
else if (items.Count == 0)
|
||||
{
|
||||
<p class="muted">هنوز نشانی ذخیره نشده است.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="simple-list">
|
||||
@foreach (var item in items)
|
||||
{
|
||||
<li>
|
||||
<a href="/tafsir/ghatreh-chekani">@item.Episode.Title</a>
|
||||
<span class="ep-meta">@item.Episode.SurahNameFa — آیه @item.Episode.AyahNumber</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private List<BookmarkDto> items = [];
|
||||
private bool loading = true;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
var deviceId = await DeviceStore.GetOrCreateAsync();
|
||||
try { items = (await Api.GetBookmarksAsync(deviceId)).ToList(); }
|
||||
catch { items = []; }
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@page "/contact"
|
||||
|
||||
<section class="prose-page">
|
||||
<h1>تماس با ما</h1>
|
||||
<p>برای پیشنهاد، گزارش اشکال یا همکاری میتوانید از ایمیل زیر استفاده کنید:</p>
|
||||
<p><a href="mailto:hello@tafsirquran.app">hello@tafsirquran.app</a></p>
|
||||
</section>
|
||||
@@ -0,0 +1,45 @@
|
||||
@page "/favorites"
|
||||
@inject TafsirApiClient Api
|
||||
@inject IDeviceIdStore DeviceStore
|
||||
|
||||
<section class="list-page">
|
||||
<h1>علاقهمندیها</h1>
|
||||
@if (loading)
|
||||
{
|
||||
<p class="muted">در حال بارگذاری...</p>
|
||||
}
|
||||
else if (items.Count == 0)
|
||||
{
|
||||
<p class="muted">هنوز علاقهمندیای ندارید.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="simple-list">
|
||||
@foreach (var item in items)
|
||||
{
|
||||
<li>
|
||||
<a href="/tafsir/ghatreh-chekani">@item.Episode.Title</a>
|
||||
<span class="ep-meta">@item.Episode.SurahNameFa — آیه @item.Episode.AyahNumber</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private List<FavoriteDto> items = [];
|
||||
private bool loading = true;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
var deviceId = await DeviceStore.GetOrCreateAsync();
|
||||
try { items = (await Api.GetFavoritesAsync(deviceId)).ToList(); }
|
||||
catch { items = []; }
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
@page "/"
|
||||
@inject TafsirApiClient Api
|
||||
|
||||
<section class="home">
|
||||
<h1 class="sr-only">تفسیر قرآن</h1>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<p class="muted">در حال بارگذاری...</p>
|
||||
}
|
||||
else if (tafsirs.Count == 0)
|
||||
{
|
||||
<a class="tafsir-tile" href="/tafsir/ghatreh-chekani">
|
||||
<span class="tile-title">تفسیر قطره چکانی</span>
|
||||
<span class="tile-author">قرائتی</span>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="tile-grid">
|
||||
@foreach (var t in tafsirs)
|
||||
{
|
||||
<a class="tafsir-tile" href="@($"/tafsir/{t.Slug}")">
|
||||
<span class="tile-title">@t.Title</span>
|
||||
<span class="tile-author">@t.Author</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private List<TafsirDto> tafsirs = [];
|
||||
private bool loading = true;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
try
|
||||
{
|
||||
tafsirs = (await Api.GetTafsirsAsync()).ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
tafsirs = [];
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
@page "/login"
|
||||
@inject TafsirApiClient Api
|
||||
@inject AuthStateService Auth
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<section class="auth-page">
|
||||
<h1>@(isRegister ? "ثبتنام" : "ورود")</h1>
|
||||
<p class="muted">اختیاری است؛ بدون حساب هم میتوانید گوش دهید و نشان بگذارید.</p>
|
||||
|
||||
<EditForm Model="model" OnValidSubmit="SubmitAsync" class="auth-form">
|
||||
@if (isRegister)
|
||||
{
|
||||
<label>
|
||||
نام نمایشی
|
||||
<InputText @bind-Value="model.DisplayName" />
|
||||
</label>
|
||||
}
|
||||
<label>
|
||||
ایمیل
|
||||
<InputText @bind-Value="model.Email" />
|
||||
</label>
|
||||
<label>
|
||||
رمز عبور
|
||||
<input type="password" @bind="model.Password" />
|
||||
</label>
|
||||
|
||||
@if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<p class="error">@error</p>
|
||||
}
|
||||
|
||||
<button class="primary-btn" type="submit" disabled="@busy">
|
||||
@(isRegister ? "ایجاد حساب" : "ورود")
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
<button class="link-btn" type="button" @onclick="() => isRegister = !isRegister">
|
||||
@(isRegister ? "حساب دارید؟ ورود" : "حساب ندارید؟ ثبتنام")
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private readonly AuthModel model = new();
|
||||
private bool isRegister;
|
||||
private bool busy;
|
||||
private string? error;
|
||||
|
||||
private async Task SubmitAsync()
|
||||
{
|
||||
busy = true;
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
var req = new AuthRequest(model.Email, model.Password, model.DisplayName);
|
||||
var result = isRegister ? await Api.RegisterAsync(req) : await Api.LoginAsync(req);
|
||||
if (result is null)
|
||||
{
|
||||
error = isRegister ? "ثبتنام ناموفق بود." : "ورود ناموفق بود.";
|
||||
return;
|
||||
}
|
||||
await Auth.SetSessionAsync(result);
|
||||
Nav.NavigateTo("/");
|
||||
}
|
||||
finally
|
||||
{
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AuthModel
|
||||
{
|
||||
public string Email { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
public string? DisplayName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
@page "/tafsir/{Slug}"
|
||||
@inject TafsirApiClient Api
|
||||
@inject IDeviceIdStore DeviceStore
|
||||
|
||||
<section class="tafsir-page">
|
||||
<header class="page-header">
|
||||
<h1>@title</h1>
|
||||
<p class="muted">@author</p>
|
||||
</header>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<p class="muted">در حال بارگذاری...</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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>
|
||||
</button>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
</div>
|
||||
|
||||
<AudioPlayer @key="detail.Id"
|
||||
Episode="detail"
|
||||
InitialPosition="initialPosition"
|
||||
IsFavorite="isFavorite"
|
||||
IsBookmarked="isBookmarked"
|
||||
OnFavoriteToggle="ToggleFavorite"
|
||||
OnBookmarkToggle="ToggleBookmark"
|
||||
OnProgress="SaveProgress" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Slug { get; set; } = "";
|
||||
|
||||
private string title = "تفسیر";
|
||||
private string author = "";
|
||||
private bool loading = true;
|
||||
private string? loadedForSlug;
|
||||
private List<EpisodeListItemDto> episodes = [];
|
||||
private EpisodeListItemDto? selected;
|
||||
private EpisodeDetailDto? detail;
|
||||
private double initialPosition;
|
||||
private bool isFavorite;
|
||||
private bool isBookmarked;
|
||||
private string deviceId = "";
|
||||
private bool ready;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender) ready = true;
|
||||
if (!ready || string.IsNullOrWhiteSpace(Slug) || loadedForSlug == Slug) return;
|
||||
|
||||
loading = true;
|
||||
loadedForSlug = Slug;
|
||||
deviceId = await DeviceStore.GetOrCreateAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var tafsirs = await Api.GetTafsirsAsync();
|
||||
var tafsir = tafsirs.FirstOrDefault(t => t.Slug == Slug);
|
||||
title = tafsir?.Title ?? "تفسیر";
|
||||
author = tafsir?.Author ?? "";
|
||||
|
||||
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)
|
||||
{
|
||||
initialPosition = progress?.EpisodeId == id ? progress.PositionSeconds : 0;
|
||||
await SelectAsync(id, keepPosition: true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SelectAsync(Guid id, bool keepPosition = false)
|
||||
{
|
||||
selected = episodes.FirstOrDefault(e => e.Id == id);
|
||||
detail = await Api.GetEpisodeAsync(id);
|
||||
if (!keepPosition) initialPosition = 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);
|
||||
}
|
||||
|
||||
private async Task ToggleFavorite()
|
||||
{
|
||||
if (detail is null) return;
|
||||
if (isFavorite)
|
||||
await Api.RemoveFavoriteAsync(detail.Id, deviceId);
|
||||
else
|
||||
await Api.AddFavoriteAsync(new ToggleEpisodeRequest(detail.Id, deviceId));
|
||||
isFavorite = !isFavorite;
|
||||
}
|
||||
|
||||
private async Task ToggleBookmark()
|
||||
{
|
||||
if (detail is null) return;
|
||||
if (isBookmarked)
|
||||
await Api.RemoveBookmarkAsync(detail.Id, deviceId);
|
||||
else
|
||||
await Api.AddBookmarkAsync(new ToggleEpisodeRequest(detail.Id, deviceId));
|
||||
isBookmarked = !isBookmarked;
|
||||
}
|
||||
|
||||
private async Task SaveProgress(double position)
|
||||
{
|
||||
if (detail is null) return;
|
||||
await Api.SaveProgressAsync(new UpsertProgressRequest(detail.Id, position, deviceId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<Router AppAssembly="typeof(Routes).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(AppLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace TafsirQuran.UI.Services;
|
||||
|
||||
public class ApiOptions
|
||||
{
|
||||
public string BaseAddress { get; set; } = "http://localhost:5080/";
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using TafsirQuran.Shared.Dtos;
|
||||
using TafsirQuran.Shared.Services;
|
||||
|
||||
namespace TafsirQuran.UI.Services;
|
||||
|
||||
public class AuthStateService(TafsirApiClient api, IAuthTokenStore tokenStore)
|
||||
{
|
||||
public UserStatusDto Current { get; private set; } = new(false, null, null, null);
|
||||
public event Action? Changed;
|
||||
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Current = await api.GetMeAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
Current = new(false, null, null, null);
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public async Task SetSessionAsync(AuthResponse? response)
|
||||
{
|
||||
if (response is null)
|
||||
{
|
||||
await tokenStore.SetTokenAsync(null);
|
||||
Current = new(false, null, null, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
await tokenStore.SetTokenAsync(response.Token);
|
||||
Current = new(true, response.UserId, response.Email, response.DisplayName);
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
try { await api.LogoutAsync(); } catch { /* ignore */ }
|
||||
await SetSessionAsync(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.JSInterop;
|
||||
using TafsirQuran.Shared.Services;
|
||||
|
||||
namespace TafsirQuran.UI.Services;
|
||||
|
||||
public class BrowserAuthTokenStore(IJSRuntime js) : IAuthTokenStore
|
||||
{
|
||||
private const string Key = "tq_auth_token";
|
||||
private string? _memory;
|
||||
private bool _loaded;
|
||||
|
||||
public async Task<string?> GetTokenAsync()
|
||||
{
|
||||
if (_loaded) return _memory;
|
||||
try
|
||||
{
|
||||
_memory = await js.InvokeAsync<string?>("tqStorage.get", Key);
|
||||
_loaded = true;
|
||||
return _memory;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return _memory;
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
return _memory;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetTokenAsync(string? token)
|
||||
{
|
||||
_memory = token;
|
||||
_loaded = true;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
await js.InvokeVoidAsync("tqStorage.remove", Key);
|
||||
else
|
||||
await js.InvokeVoidAsync("tqStorage.set", Key, token);
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
catch (JSException) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.JSInterop;
|
||||
using TafsirQuran.Shared.Services;
|
||||
|
||||
namespace TafsirQuran.UI.Services;
|
||||
|
||||
public class BrowserDeviceIdStore(IJSRuntime js) : IDeviceIdStore
|
||||
{
|
||||
private const string Key = "tq_device_id";
|
||||
private string? _memory;
|
||||
|
||||
public async Task<string> GetOrCreateAsync()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_memory))
|
||||
return _memory;
|
||||
|
||||
try
|
||||
{
|
||||
var existing = await js.InvokeAsync<string?>("tqStorage.get", Key);
|
||||
if (!string.IsNullOrWhiteSpace(existing))
|
||||
{
|
||||
_memory = existing;
|
||||
return existing;
|
||||
}
|
||||
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
await js.InvokeVoidAsync("tqStorage.set", Key, id);
|
||||
_memory = id;
|
||||
return id;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// JS not available yet (prerender) — temporary id until after render
|
||||
_memory ??= Guid.NewGuid().ToString("N");
|
||||
return _memory;
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
_memory ??= Guid.NewGuid().ToString("N");
|
||||
return _memory;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TafsirQuran.Shared.Services;
|
||||
|
||||
namespace TafsirQuran.UI.Services;
|
||||
|
||||
public static class UiServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddTafsirUi(this IServiceCollection services, string apiBaseAddress)
|
||||
{
|
||||
var normalized = apiBaseAddress.TrimEnd('/') + "/";
|
||||
services.AddSingleton(new ApiOptions { BaseAddress = normalized });
|
||||
services.AddScoped<IDeviceIdStore, BrowserDeviceIdStore>();
|
||||
services.AddScoped<IAuthTokenStore, BrowserAuthTokenStore>();
|
||||
services.AddScoped<AuthStateService>();
|
||||
services.AddScoped(sp =>
|
||||
{
|
||||
var http = new HttpClient { BaseAddress = new Uri(normalized) };
|
||||
var tokens = sp.GetRequiredService<IAuthTokenStore>();
|
||||
return new TafsirApiClient(http, tokens);
|
||||
});
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<SupportedPlatform Include="browser" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\TafsirQuran.Shared\TafsirQuran.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using TafsirQuran.Shared.Dtos
|
||||
@using TafsirQuran.Shared.Services
|
||||
@using TafsirQuran.UI
|
||||
@using TafsirQuran.UI.Components
|
||||
@using TafsirQuran.UI.Layout
|
||||
@using TafsirQuran.UI.Pages
|
||||
@using TafsirQuran.UI.Services
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 378 B |
@@ -0,0 +1,228 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;700&family=Amiri:wght@400;700&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #f3efe6;
|
||||
--bg-accent: #e7efe8;
|
||||
--ink: #1c2b24;
|
||||
--muted: #5d6b63;
|
||||
--brand: #1f6b4a;
|
||||
--brand-deep: #134632;
|
||||
--surface: rgba(255, 255, 255, 0.72);
|
||||
--line: rgba(28, 43, 36, 0.12);
|
||||
--danger: #9b3b3b;
|
||||
--radius: 18px;
|
||||
--shadow: 0 18px 40px rgba(28, 43, 36, 0.08);
|
||||
--sidebar-width: 280px;
|
||||
--font: "Vazirmatn", sans-serif;
|
||||
--font-ar: "Amiri", serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font);
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(1200px 600px at 100% -10%, #dce9df 0%, transparent 55%),
|
||||
radial-gradient(900px 500px at -10% 100%, #efe4d2 0%, transparent 50%),
|
||||
var(--bg);
|
||||
min-height: 100%;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input { font: inherit; }
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0,0,0,0); border: 0;
|
||||
}
|
||||
|
||||
.app-shell { min-height: 100vh; }
|
||||
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
backdrop-filter: blur(14px);
|
||||
background: rgba(243, 239, 230, 0.82);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 42px; height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
display: grid; place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.burger, .burger::before, .burger::after {
|
||||
display: block; width: 16px; height: 2px; background: var(--ink); border-radius: 2px;
|
||||
content: ""; position: relative;
|
||||
}
|
||||
.burger::before { top: -5px; position: absolute; }
|
||||
.burger::after { top: 5px; position: absolute; }
|
||||
|
||||
.sidebar {
|
||||
position: fixed; inset: 0 auto 0 0; /* LTR physical: for RTL put on right */
|
||||
right: 0; left: auto;
|
||||
width: min(var(--sidebar-width), 86vw);
|
||||
background: #163528;
|
||||
color: #f4f7f5;
|
||||
transform: translateX(105%);
|
||||
transition: transform .28s ease;
|
||||
z-index: 30;
|
||||
display: flex; flex-direction: column;
|
||||
padding: 1.25rem 0.75rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.app-shell.sidebar-open .sidebar { transform: translateX(0); }
|
||||
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; background: rgba(10, 18, 14, 0.35);
|
||||
opacity: 0; pointer-events: none; transition: opacity .28s ease; z-index: 25;
|
||||
}
|
||||
.app-shell.sidebar-open .backdrop { opacity: 1; pointer-events: auto; }
|
||||
|
||||
.side-nav { display: flex; flex-direction: column; height: 100%; gap: 1rem; }
|
||||
.side-section { display: flex; flex-direction: column; gap: 0.25rem; flex: 1; }
|
||||
.side-footer { border-top: 1px solid rgba(255,255,255,0.12); padding-top: 0.75rem; }
|
||||
|
||||
.side-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.85rem 0.9rem;
|
||||
border-radius: 12px;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
}
|
||||
.side-item:hover, .side-item.active { background: rgba(255,255,255,0.08); }
|
||||
.side-item.nested { padding-right: 1.6rem; font-size: 0.95rem; color: #d7e6dd; }
|
||||
.side-item.danger { color: #ffc9c9; }
|
||||
.cascade { display: flex; flex-direction: column; gap: 0.15rem; }
|
||||
.chevron { transition: transform .2s ease; opacity: .8; }
|
||||
.chevron.open { transform: rotate(180deg); }
|
||||
.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; }
|
||||
|
||||
.home { padding-top: 1.5rem; }
|
||||
.tile-grid { display: grid; gap: 1rem; }
|
||||
.tafsir-tile {
|
||||
display: flex; flex-direction: column; justify-content: flex-end;
|
||||
min-height: 220px;
|
||||
padding: 1.5rem;
|
||||
border-radius: 28px;
|
||||
background:
|
||||
linear-gradient(160deg, rgba(31,107,74,0.92), rgba(19,70,50,0.95)),
|
||||
radial-gradient(circle at 20% 20%, rgba(255,255,255,0.18), transparent 40%);
|
||||
color: #f7faf8;
|
||||
box-shadow: var(--shadow);
|
||||
transition: transform .2s ease, box-shadow .2s ease;
|
||||
}
|
||||
.tafsir-tile:hover { transform: translateY(-3px); box-shadow: 0 22px 48px rgba(19,70,50,0.22); }
|
||||
.tile-title { font-size: clamp(1.6rem, 4vw, 2.2rem); font-weight: 700; }
|
||||
.tile-author { margin-top: 0.35rem; font-size: 0.95rem; opacity: 0.85; }
|
||||
|
||||
.page-header h1, .list-page h1, .prose-page h1, .auth-page h1 {
|
||||
margin: 0 0 0.35rem; font-size: 1.6rem;
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
.error { color: var(--danger); }
|
||||
|
||||
.episode-layout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (min-width: 860px) {
|
||||
.episode-layout { grid-template-columns: 1fr 1.1fr; align-items: start; }
|
||||
}
|
||||
|
||||
.episode-list {
|
||||
display: flex; flex-direction: column; gap: 0.45rem;
|
||||
max-height: 60vh; overflow: auto; padding-left: 0.25rem;
|
||||
}
|
||||
.episode-row {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: 14px;
|
||||
padding: 0.85rem 1rem;
|
||||
text-align: right;
|
||||
cursor: pointer;
|
||||
display: flex; flex-direction: column; gap: 0.2rem;
|
||||
}
|
||||
.episode-row.active, .episode-row:hover {
|
||||
border-color: rgba(31,107,74,0.45);
|
||||
background: #fff;
|
||||
}
|
||||
.ep-title { font-weight: 600; }
|
||||
.ep-meta { color: var(--muted); font-size: 0.85rem; }
|
||||
|
||||
.player-panel {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
border-radius: 22px;
|
||||
padding: 1.1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.ayah-block { margin-bottom: 1rem; }
|
||||
.ayah-ar {
|
||||
font-family: var(--font-ar);
|
||||
font-size: clamp(1.4rem, 3vw, 1.9rem);
|
||||
line-height: 2;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.ayah-fa { margin: 0; color: var(--muted); }
|
||||
|
||||
.audio-player { display: flex; flex-direction: column; gap: 0.85rem; }
|
||||
.player-title { font-weight: 600; }
|
||||
.player-actions { display: flex; gap: 0.5rem; }
|
||||
.chip {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip.on { background: var(--brand); color: #fff; border-color: var(--brand); }
|
||||
.player-controls { display: grid; grid-template-columns: auto 1fr auto; gap: 0.75rem; align-items: center; }
|
||||
.play-btn {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
border: 0; background: var(--brand); color: #fff; cursor: pointer;
|
||||
}
|
||||
.seek { width: 100%; accent-color: var(--brand); }
|
||||
.time { font-variant-numeric: tabular-nums; font-size: 0.85rem; color: var(--muted); white-space: nowrap; }
|
||||
|
||||
.simple-list { list-style: none; padding: 0; margin: 1rem 0 0; display: grid; gap: 0.6rem; }
|
||||
.simple-list li {
|
||||
padding: 0.9rem 1rem; border-radius: 14px; background: var(--surface); border: 1px solid var(--line);
|
||||
display: flex; flex-direction: column; gap: 0.2rem;
|
||||
}
|
||||
|
||||
.prose-page p, .auth-page p { line-height: 1.9; color: var(--muted); }
|
||||
.auth-form { display: grid; gap: 0.85rem; max-width: 420px; margin-top: 1rem; }
|
||||
.auth-form label { display: grid; gap: 0.35rem; font-size: 0.95rem; }
|
||||
.auth-form input {
|
||||
border: 1px solid var(--line); border-radius: 12px; padding: 0.75rem 0.9rem; background: #fff;
|
||||
}
|
||||
.primary-btn {
|
||||
border: 0; border-radius: 12px; padding: 0.85rem 1rem;
|
||||
background: var(--brand); color: #fff; cursor: pointer;
|
||||
}
|
||||
.primary-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.link-btn {
|
||||
margin-top: 0.85rem; border: 0; background: transparent; color: var(--brand-deep); cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
window.tqStorage = {
|
||||
get: (key) => localStorage.getItem(key),
|
||||
set: (key, value) => localStorage.setItem(key, value),
|
||||
remove: (key) => localStorage.removeItem(key)
|
||||
};
|
||||
|
||||
window.tqAudio = {
|
||||
_handlers: new WeakMap(),
|
||||
|
||||
bind: (audio, dotnet) => {
|
||||
if (!audio) return;
|
||||
const onTime = () => dotnet.invokeMethodAsync('OnTime', audio.currentTime || 0, audio.duration || 0, !audio.paused);
|
||||
const onPause = () => dotnet.invokeMethodAsync('OnPaused', audio.currentTime || 0);
|
||||
const onEnded = () => dotnet.invokeMethodAsync('OnPaused', audio.currentTime || 0);
|
||||
audio.addEventListener('timeupdate', onTime);
|
||||
audio.addEventListener('pause', onPause);
|
||||
audio.addEventListener('ended', onEnded);
|
||||
audio.addEventListener('loadedmetadata', onTime);
|
||||
window.tqAudio._handlers.set(audio, { onTime, onPause, onEnded, dotnet });
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (!audio.paused) {
|
||||
dotnet.invokeMethodAsync('OnPaused', audio.currentTime || 0);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
unbind: (audio) => {
|
||||
const h = window.tqAudio._handlers.get(audio);
|
||||
if (!h || !audio) return;
|
||||
audio.removeEventListener('timeupdate', h.onTime);
|
||||
audio.removeEventListener('pause', h.onPause);
|
||||
audio.removeEventListener('ended', h.onEnded);
|
||||
window.tqAudio._handlers.delete(audio);
|
||||
},
|
||||
|
||||
load: async (audio, src, position) => {
|
||||
if (!audio) return;
|
||||
audio.src = src;
|
||||
audio.load();
|
||||
const seek = () => {
|
||||
if (position && !Number.isNaN(position)) {
|
||||
try { audio.currentTime = position; } catch (_) { /* ignore */ }
|
||||
}
|
||||
};
|
||||
audio.addEventListener('loadedmetadata', seek, { once: true });
|
||||
},
|
||||
|
||||
toggle: async (audio) => {
|
||||
if (!audio) return false;
|
||||
if (audio.paused) {
|
||||
await audio.play();
|
||||
return true;
|
||||
}
|
||||
audio.pause();
|
||||
return false;
|
||||
},
|
||||
|
||||
seek: (audio, value) => {
|
||||
if (!audio) return;
|
||||
audio.currentTime = Number(value) || 0;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user