Initial TafsirQuran app with Gitea CI/CD deploy to k3s.
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:
2026-10-09 21:50:37 +03:30
commit 7f06856f45
124 changed files with 4729 additions and 0 deletions
@@ -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();
}
}
+42
View File
@@ -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;
}
+50
View File
@@ -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("/");
}
}
+9
View File
@@ -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);
}
}
}
+53
View File
@@ -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);
}
}
}
+76
View File
@@ -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; }
}
}
+148
View File
@@ -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));
}
}
+6
View File
@@ -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;
}
}
+22
View File
@@ -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>
+14
View File
@@ -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

+228
View File
@@ -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;
}
+63
View File
@@ -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;
}
};