From 9309a1e6de302fe9e7511fc6bda35384b0c7182d Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 10 Oct 2026 00:24:02 +0330 Subject: [PATCH] Add Surah/Ayah browse with Gharaati Tafsir Noor crawl. Replace episode bookmarks with surah/ayah bookmarks and ayah favorites; import ayah text, notes, messages, and official per-ayah audio from gharaati.ir. --- k8s/gharaati-crawl-job.yaml | 44 ++ scripts/crawl-gharaati.py | 161 +++++ .../Endpoints/BookmarkEndpoints.cs | 89 ++- .../Endpoints/FavoriteEndpoints.cs | 37 +- .../Endpoints/ProgressEndpoints.cs | 19 +- .../Endpoints/TafsirEndpoints.cs | 216 +++++-- src/TafsirQuran.Domain/Entities/AppUser.cs | 5 +- .../Entities/AyahBookmark.cs | 17 + .../Entities/{Favorite.cs => AyahFavorite.cs} | 9 +- .../Entities/ListeningProgress.cs | 7 +- .../{Bookmark.cs => SurahBookmark.cs} | 8 +- src/TafsirQuran.Domain/Entities/Tafsir.cs | 1 + .../Entities/TafsirAyahContent.cs | 18 + .../Persistence/AppDbContext.cs | 54 +- .../Persistence/DbSeeder.cs | 170 +++--- ...09205128_AyahBrowseAndGharaati.Designer.cs | 576 ++++++++++++++++++ .../20261009205128_AyahBrowseAndGharaati.cs | 422 +++++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 242 +++++++- src/TafsirQuran.Shared/Dtos/TafsirDtos.cs | 103 +++- .../Services/TafsirApiClient.cs | 64 +- .../Components/AudioPlayer.razor | 55 +- src/TafsirQuran.UI/Pages/BookmarksPage.razor | 63 +- src/TafsirQuran.UI/Pages/FavoritesPage.razor | 24 +- src/TafsirQuran.UI/Pages/TafsirPage.razor | 253 ++++++-- src/TafsirQuran.UI/wwwroot/css/app.css | 46 ++ 25 files changed, 2340 insertions(+), 363 deletions(-) create mode 100644 k8s/gharaati-crawl-job.yaml create mode 100755 scripts/crawl-gharaati.py create mode 100644 src/TafsirQuran.Domain/Entities/AyahBookmark.cs rename src/TafsirQuran.Domain/Entities/{Favorite.cs => AyahFavorite.cs} (56%) rename src/TafsirQuran.Domain/Entities/{Bookmark.cs => SurahBookmark.cs} (63%) create mode 100644 src/TafsirQuran.Domain/Entities/TafsirAyahContent.cs create mode 100644 src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009205128_AyahBrowseAndGharaati.Designer.cs create mode 100644 src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009205128_AyahBrowseAndGharaati.cs diff --git a/k8s/gharaati-crawl-job.yaml b/k8s/gharaati-crawl-job.yaml new file mode 100644 index 0000000..4e9e5b2 --- /dev/null +++ b/k8s/gharaati-crawl-job.yaml @@ -0,0 +1,44 @@ +# One-shot crawl of Tafsir Noor into tafsirquran-audio PVC. +# Scale API to 0 first (RWO PVC), then create ConfigMap + Job: +# kubectl -n tafsirquran create configmap tafsirquran-gharaati-scripts \ +# --from-file=crawl-gharaati.py=scripts/crawl-gharaati.py --dry-run=client -o yaml | kubectl apply -f - +# kubectl -n tafsirquran apply -f k8s/gharaati-crawl-job.yaml +# kubectl -n tafsirquran logs -f job/tafsirquran-gharaati-crawl +apiVersion: batch/v1 +kind: Job +metadata: + name: tafsirquran-gharaati-crawl + namespace: tafsirquran +spec: + backoffLimit: 2 + ttlSecondsAfterFinished: 86400 + template: + spec: + restartPolicy: Never + containers: + - name: crawl + image: python:3.12-slim + command: ["/bin/bash", "-lc"] + args: + - | + set -euo pipefail + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ca-certificates >/dev/null + python3 /scripts/crawl-gharaati.py /data/audio --skip-existing-json --delay 0.25 + volumeMounts: + - name: audio + mountPath: /data/audio + - name: scripts + mountPath: /scripts + resources: + requests: + memory: 256Mi + cpu: 250m + volumes: + - name: audio + persistentVolumeClaim: + claimName: tafsirquran-audio + - name: scripts + configMap: + name: tafsirquran-gharaati-scripts + defaultMode: 0755 diff --git a/scripts/crawl-gharaati.py b/scripts/crawl-gharaati.py new file mode 100755 index 0000000..a62f08d --- /dev/null +++ b/scripts/crawl-gharaati.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Crawl Tafsir Noor from gharaati.ir into AUDIO_DIR/gharaati + content JSON.""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request +from html import unescape +from pathlib import Path + +BASE = "https://gharaati.ir" +UA = "TafsirQuranCrawler/1.0 (+local; educational mirror)" + + +def fetch(url: str, retries: int = 5) -> bytes: + last: Exception | None = None + for i in range(retries): + try: + req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "*/*"}) + with urllib.request.urlopen(req, timeout=60) as resp: + return resp.read() + except Exception as ex: # noqa: BLE001 + last = ex + time.sleep(1.5 * (i + 1)) + raise RuntimeError(f"fetch failed {url}: {last}") + + +def strip_tags(html: str) -> str: + text = re.sub(r"", "\n", html, flags=re.I) + text = re.sub(r"", "\n", text, flags=re.I) + text = re.sub(r"<[^>]+>", "", text) + return unescape(re.sub(r"\s+\n", "\n", text)).strip() + + +def section_after_title(html: str, title: str) -> str | None: + # Match heading text then first content cell with class tafsir or tafsir_ayeh + pattern = ( + rf'\s*{re.escape(title)}\s*.*?' + rf']*class=[\'"]?(?:tafsir_ayeh|tafsir)[\'"]?[^>]*>(.*?)' + ) + m = re.search(pattern, html, flags=re.I | re.S) + if not m: + # alternate spellings + return None + return m.group(1).strip() + + +def parse_show3(html: str) -> dict: + # Normalize common title variants + ayah_html = section_after_title(html, "آيه") or section_after_title(html, "آیه") + tarjome = section_after_title(html, "ترجمه") + nokteh = section_after_title(html, "نکته ها") or section_after_title(html, "نكته ها") + payam = ( + section_after_title(html, "پيام ها") + or section_after_title(html, "پیام ها") + or section_after_title(html, "پيامها") + ) + video = None + vm = re.search(r'src=["\'](https?://[^"\']*aparat\.com[^"\']*)["\']', html, flags=re.I) + if vm: + video = vm.group(1) + + return { + "textAr": strip_tags(ayah_html) if ayah_html else "", + "textFa": strip_tags(tarjome) if tarjome else "", + "notesHtml": nokteh or "", + "messagesHtml": payam or "", + "videoEmbedUrl": video, + } + + +def ayah_list(surah: int) -> list[int]: + raw = fetch(f"{BASE}/mydata.php?op=getayehlist&id={surah}").decode("utf-8", "replace") + nums = [int(x) for x in re.findall(r"value=['\"]?(\d+)", raw)] + return sorted(set(nums)) if nums else [] + + +def download_mp3(surah: int, ayah: int, dest: Path) -> bool: + if dest.exists() and dest.stat().st_size > 1000: + return False + dest.parent.mkdir(parents=True, exist_ok=True) + url = f"{BASE}/archive/tafsir/{surah}/{ayah}.mp3" + try: + data = fetch(url, retries=3) + if len(data) < 500: + return False + tmp = dest.with_suffix(".part") + tmp.write_bytes(data) + tmp.replace(dest) + return True + except Exception as ex: # noqa: BLE001 + print(f" mp3 miss {surah}/{ayah}: {ex}", file=sys.stderr) + return False + + +def crawl_one(surah: int, ayah: int, audio_dir: Path, delay: float) -> dict: + html = fetch(f"{BASE}/show3.php?page=tafsir3&numsooreh={surah}&numayeh={ayah}").decode( + "utf-8", "replace" + ) + parsed = parse_show3(html) + rel = f"gharaati/{surah}/{ayah}.mp3" + mp3_path = audio_dir / rel + download_mp3(surah, ayah, mp3_path) + if mp3_path.exists(): + parsed["audioPath"] = rel + else: + parsed["audioPath"] = None + parsed["surah"] = surah + parsed["ayah"] = ayah + content_path = audio_dir / "gharaati" / "content" / str(surah) / f"{ayah}.json" + content_path.parent.mkdir(parents=True, exist_ok=True) + content_path.write_text(json.dumps(parsed, ensure_ascii=False, indent=2), encoding="utf-8") + time.sleep(delay) + return parsed + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("audio_dir", nargs="?", default=os.environ.get("AUDIO_DIR", "/data/audio")) + ap.add_argument("--surah-from", type=int, default=1) + ap.add_argument("--surah-to", type=int, default=114) + ap.add_argument("--delay", type=float, default=0.35) + ap.add_argument("--skip-existing-json", action="store_true") + args = ap.parse_args() + audio_dir = Path(args.audio_dir) + audio_dir.mkdir(parents=True, exist_ok=True) + + total = 0 + for s in range(args.surah_from, args.surah_to + 1): + ayahs = ayah_list(s) + if not ayahs: + print(f"surah {s}: no ayah list", file=sys.stderr) + continue + print(f"surah {s}: {len(ayahs)} ayahs") + for a in ayahs: + jpath = audio_dir / "gharaati" / "content" / str(s) / f"{a}.json" + if args.skip_existing_json and jpath.exists(): + # still try mp3 if missing + mp3 = audio_dir / "gharaati" / str(s) / f"{a}.mp3" + if not mp3.exists(): + download_mp3(s, a, mp3) + continue + try: + crawl_one(s, a, audio_dir, args.delay) + total += 1 + if total % 25 == 0: + print(f" crawled {total} ayahs (at {s}:{a})") + except Exception as ex: # noqa: BLE001 + print(f"FAIL {s}:{a} {ex}", file=sys.stderr) + time.sleep(2) + print(f"done. crawled={total}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/TafsirQuran.Api/Endpoints/BookmarkEndpoints.cs b/src/TafsirQuran.Api/Endpoints/BookmarkEndpoints.cs index 420c508..2d08125 100644 --- a/src/TafsirQuran.Api/Endpoints/BookmarkEndpoints.cs +++ b/src/TafsirQuran.Api/Endpoints/BookmarkEndpoints.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using Microsoft.EntityFrameworkCore; using TafsirQuran.Api.Auth; -using TafsirQuran.Api.Mapping; using TafsirQuran.Domain.Entities; using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Shared.Dtos; @@ -12,52 +11,106 @@ public static class BookmarkEndpoints { public static RouteGroupBuilder MapBookmarkEndpoints(this WebApplication app) { - var group = app.MapGroup("/api/bookmarks"); + var surahGroup = app.MapGroup("/api/bookmarks/surahs"); + var ayahGroup = app.MapGroup("/api/bookmarks/ayahs"); - group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + surahGroup.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { var userId = JwtTokenService.GetUserId(user); - var items = await db.Bookmarks.AsNoTracking() - .Include(b => b.Episode).ThenInclude(e => e.Surah) + var items = await db.SurahBookmarks.AsNoTracking() + .Include(b => b.Surah) .Where(b => b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId)) .OrderByDescending(b => b.CreatedAt) .ToListAsync(ct); - return Results.Ok(items.Select(b => new BookmarkDto( - b.Id, b.EpisodeId, b.Note, b.CreatedAt, EpisodeMapper.ToListItem(b.Episode))).ToList()); + return Results.Ok(items.Select(b => new SurahBookmarkDto( + b.Id, b.TafsirId, b.SurahId, b.Surah.NameFa, b.Note, b.CreatedAt)).ToList()); }); - group.MapPost("/", async (ToggleEpisodeRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + surahGroup.MapPost("/", async (ToggleSurahBookmarkRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { var userId = JwtTokenService.GetUserId(user); - var exists = await db.Bookmarks.AnyAsync(b => - b.EpisodeId == request.EpisodeId && + var exists = await db.SurahBookmarks.AnyAsync(b => + b.TafsirId == request.TafsirId && + b.SurahId == request.SurahNumber && (b.DeviceId == request.DeviceId || (userId.HasValue && b.UserId == userId)), ct); if (exists) return Results.NoContent(); - db.Bookmarks.Add(new Bookmark + db.SurahBookmarks.Add(new SurahBookmark { DeviceId = request.DeviceId, UserId = userId, - EpisodeId = request.EpisodeId, + TafsirId = request.TafsirId, + SurahId = request.SurahNumber, Note = request.Note }); await db.SaveChangesAsync(ct); - return Results.Created($"/api/bookmarks/{request.EpisodeId}", null); + return Results.Created($"/api/bookmarks/surahs/{request.SurahNumber}", null); }); - group.MapDelete("/{episodeId:guid}", async (Guid episodeId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + surahGroup.MapDelete("/{surahNumber:int}", async ( + int surahNumber, Guid tafsirId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { var userId = JwtTokenService.GetUserId(user); - var items = await db.Bookmarks - .Where(b => b.EpisodeId == episodeId && + var items = await db.SurahBookmarks + .Where(b => b.TafsirId == tafsirId && b.SurahId == surahNumber && (b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId))) .ToListAsync(ct); - db.Bookmarks.RemoveRange(items); + db.SurahBookmarks.RemoveRange(items); await db.SaveChangesAsync(ct); return Results.NoContent(); }); - return group; + ayahGroup.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + { + var userId = JwtTokenService.GetUserId(user); + var items = await db.AyahBookmarks.AsNoTracking() + .Include(b => b.Surah) + .Include(b => b.Tafsir) + .Where(b => b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId)) + .OrderByDescending(b => b.CreatedAt) + .ToListAsync(ct); + + return Results.Ok(items.Select(b => new AyahBookmarkDto( + b.Id, b.TafsirId, b.Tafsir.Slug, b.SurahId, b.Surah.NameFa, b.AyahNumber, b.Note, b.CreatedAt)).ToList()); + }); + + ayahGroup.MapPost("/", async (ToggleAyahRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + { + var userId = JwtTokenService.GetUserId(user); + var exists = await db.AyahBookmarks.AnyAsync(b => + b.TafsirId == request.TafsirId && + b.SurahId == request.SurahNumber && + b.AyahNumber == request.AyahNumber && + (b.DeviceId == request.DeviceId || (userId.HasValue && b.UserId == userId)), ct); + if (exists) return Results.NoContent(); + + db.AyahBookmarks.Add(new AyahBookmark + { + DeviceId = request.DeviceId, + UserId = userId, + TafsirId = request.TafsirId, + SurahId = request.SurahNumber, + AyahNumber = request.AyahNumber, + Note = request.Note + }); + await db.SaveChangesAsync(ct); + return Results.Created($"/api/bookmarks/ayahs/{request.SurahNumber}/{request.AyahNumber}", null); + }); + + ayahGroup.MapDelete("/{surahNumber:int}/{ayahNumber:int}", async ( + int surahNumber, int ayahNumber, Guid tafsirId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + { + var userId = JwtTokenService.GetUserId(user); + var items = await db.AyahBookmarks + .Where(b => b.TafsirId == tafsirId && b.SurahId == surahNumber && b.AyahNumber == ayahNumber && + (b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId))) + .ToListAsync(ct); + db.AyahBookmarks.RemoveRange(items); + await db.SaveChangesAsync(ct); + return Results.NoContent(); + }); + + return surahGroup; } } diff --git a/src/TafsirQuran.Api/Endpoints/FavoriteEndpoints.cs b/src/TafsirQuran.Api/Endpoints/FavoriteEndpoints.cs index 219eab6..4ad3da7 100644 --- a/src/TafsirQuran.Api/Endpoints/FavoriteEndpoints.cs +++ b/src/TafsirQuran.Api/Endpoints/FavoriteEndpoints.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using Microsoft.EntityFrameworkCore; using TafsirQuran.Api.Auth; -using TafsirQuran.Api.Mapping; using TafsirQuran.Domain.Entities; using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Shared.Dtos; @@ -12,47 +11,53 @@ public static class FavoriteEndpoints { public static RouteGroupBuilder MapFavoriteEndpoints(this WebApplication app) { - var group = app.MapGroup("/api/favorites"); + var group = app.MapGroup("/api/favorites/ayahs"); group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { var userId = JwtTokenService.GetUserId(user); - var items = await db.Favorites.AsNoTracking() - .Include(f => f.Episode).ThenInclude(e => e.Surah) + var items = await db.AyahFavorites.AsNoTracking() + .Include(f => f.Surah) + .Include(f => f.Tafsir) .Where(f => f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId)) .OrderByDescending(f => f.CreatedAt) .ToListAsync(ct); - return Results.Ok(items.Select(f => new FavoriteDto( - f.Id, f.EpisodeId, f.CreatedAt, EpisodeMapper.ToListItem(f.Episode))).ToList()); + return Results.Ok(items.Select(f => new AyahFavoriteDto( + f.Id, f.TafsirId, f.Tafsir.Slug, f.SurahId, f.Surah.NameFa, f.AyahNumber, f.CreatedAt)).ToList()); }); - group.MapPost("/", async (ToggleEpisodeRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + group.MapPost("/", async (ToggleAyahRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { var userId = JwtTokenService.GetUserId(user); - var exists = await db.Favorites.AnyAsync(f => - f.EpisodeId == request.EpisodeId && + var exists = await db.AyahFavorites.AnyAsync(f => + f.TafsirId == request.TafsirId && + f.SurahId == request.SurahNumber && + f.AyahNumber == request.AyahNumber && (f.DeviceId == request.DeviceId || (userId.HasValue && f.UserId == userId)), ct); if (exists) return Results.NoContent(); - db.Favorites.Add(new Favorite + db.AyahFavorites.Add(new AyahFavorite { DeviceId = request.DeviceId, UserId = userId, - EpisodeId = request.EpisodeId + TafsirId = request.TafsirId, + SurahId = request.SurahNumber, + AyahNumber = request.AyahNumber }); await db.SaveChangesAsync(ct); - return Results.Created($"/api/favorites/{request.EpisodeId}", null); + return Results.Created($"/api/favorites/ayahs/{request.SurahNumber}/{request.AyahNumber}", null); }); - group.MapDelete("/{episodeId:guid}", async (Guid episodeId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => + group.MapDelete("/{surahNumber:int}/{ayahNumber:int}", async ( + int surahNumber, int ayahNumber, Guid tafsirId, string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { var userId = JwtTokenService.GetUserId(user); - var items = await db.Favorites - .Where(f => f.EpisodeId == episodeId && + var items = await db.AyahFavorites + .Where(f => f.TafsirId == tafsirId && f.SurahId == surahNumber && f.AyahNumber == ayahNumber && (f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId))) .ToListAsync(ct); - db.Favorites.RemoveRange(items); + db.AyahFavorites.RemoveRange(items); await db.SaveChangesAsync(ct); return Results.NoContent(); }); diff --git a/src/TafsirQuran.Api/Endpoints/ProgressEndpoints.cs b/src/TafsirQuran.Api/Endpoints/ProgressEndpoints.cs index 8ae2641..019b49f 100644 --- a/src/TafsirQuran.Api/Endpoints/ProgressEndpoints.cs +++ b/src/TafsirQuran.Api/Endpoints/ProgressEndpoints.cs @@ -1,7 +1,6 @@ using System.Security.Claims; using Microsoft.EntityFrameworkCore; using TafsirQuran.Api.Auth; -using TafsirQuran.Api.Mapping; using TafsirQuran.Domain.Entities; using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Shared.Dtos; @@ -17,22 +16,22 @@ public static class ProgressEndpoints group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => { var userId = JwtTokenService.GetUserId(user); - var query = db.ListeningProgresses.AsNoTracking().Include(p => p.Episode).ThenInclude(e => e.Surah).AsQueryable(); + var query = db.ListeningProgresses.AsNoTracking().Include(p => p.Surah).AsQueryable(); ListeningProgress? progress = null; if (userId.HasValue) - { progress = await query.Where(p => p.UserId == userId).OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync(ct); - } progress ??= await query.Where(p => p.DeviceId == deviceId).OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync(ct); if (progress is null) return Results.NoContent(); return Results.Ok(new ProgressDto( - progress.EpisodeId, + progress.TafsirId, + progress.SurahId, + progress.AyahNumber, progress.PositionSeconds, progress.UpdatedAt, - EpisodeMapper.ToListItem(progress.Episode))); + progress.Surah.NameFa)); }); group.MapPut("/", async (UpsertProgressRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) => @@ -52,14 +51,18 @@ public static class ProgressEndpoints { DeviceId = request.DeviceId, UserId = userId, - EpisodeId = request.EpisodeId, + TafsirId = request.TafsirId, + SurahId = request.SurahNumber, + AyahNumber = request.AyahNumber, PositionSeconds = request.PositionSeconds }; db.ListeningProgresses.Add(existing); } else { - existing.EpisodeId = request.EpisodeId; + existing.TafsirId = request.TafsirId; + existing.SurahId = request.SurahNumber; + existing.AyahNumber = request.AyahNumber; existing.PositionSeconds = request.PositionSeconds; existing.UpdatedAt = DateTimeOffset.UtcNow; existing.UserId ??= userId; diff --git a/src/TafsirQuran.Api/Endpoints/TafsirEndpoints.cs b/src/TafsirQuran.Api/Endpoints/TafsirEndpoints.cs index 19deb2b..ceb8fbb 100644 --- a/src/TafsirQuran.Api/Endpoints/TafsirEndpoints.cs +++ b/src/TafsirQuran.Api/Endpoints/TafsirEndpoints.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using TafsirQuran.Api.Mapping; using TafsirQuran.Infrastructure.Persistence; using TafsirQuran.Shared.Dtos; @@ -22,67 +21,178 @@ public static class TafsirEndpoints return Results.Ok(items); }); - group.MapGet("/tafsirs/{slug}/episodes", async (string slug, AppDbContext db, CancellationToken ct) => + group.MapGet("/surahs", async (AppDbContext db, string? deviceId, Guid? tafsirId, CancellationToken ct) => + { + var tid = tafsirId ?? DbSeeder.GhatrehChekaniId; + var bookmarked = new HashSet(); + if (!string.IsNullOrWhiteSpace(deviceId)) + { + bookmarked = (await db.SurahBookmarks.AsNoTracking() + .Where(b => b.TafsirId == tid && b.DeviceId == deviceId) + .Select(b => b.SurahId) + .ToListAsync(ct)) + .ToHashSet(); + } + + var ayahCounts = await db.Ayahs.AsNoTracking() + .GroupBy(a => a.SurahId) + .Select(g => new { SurahId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.SurahId, x => x.Count, ct); + + // Fallback: content rows if ayahs not yet imported for a surah + var contentCounts = await db.TafsirAyahContents.AsNoTracking() + .Where(c => c.TafsirId == tid) + .GroupBy(c => c.SurahId) + .Select(g => new { SurahId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.SurahId, x => x.Count, ct); + + var surahs = await db.Surahs.AsNoTracking().OrderBy(s => s.Number).ToListAsync(ct); + var result = surahs.Select(s => + { + var count = ayahCounts.GetValueOrDefault(s.Id); + if (count == 0) count = contentCounts.GetValueOrDefault(s.Id); + return new SurahListItemDto(s.Number, s.NameAr, s.NameFa, count, bookmarked.Contains(s.Id)); + }).ToList(); + + return Results.Ok(result); + }); + + group.MapGet("/tafsirs/{slug}/surahs/{surahNumber:int}/ayahs", async ( + string slug, int surahNumber, string? deviceId, AppDbContext db, CancellationToken ct) => { var tafsir = await db.Tafsirs.AsNoTracking().FirstOrDefaultAsync(t => t.Slug == slug && t.IsPublished, ct); if (tafsir is null) return Results.NotFound(); - var episodes = await db.Episodes - .AsNoTracking() - .Include(e => e.Surah) - .Where(e => e.TafsirId == tafsir.Id) - .OrderBy(e => e.SortOrder) - .ToListAsync(ct); + var surah = await db.Surahs.AsNoTracking().FirstOrDefaultAsync(s => s.Number == surahNumber, ct); + if (surah is null) return Results.NotFound(); - return Results.Ok(episodes.Select(e => EpisodeMapper.ToListItem(e)).ToList()); - }); - - group.MapGet("/episodes/{id:guid}", async (Guid id, AppDbContext db, CancellationToken ct) => - { - var episode = await db.Episodes - .AsNoTracking() - .Include(e => e.Surah) - .Include(e => e.Tafsir) - .FirstOrDefaultAsync(e => e.Id == id, ct); - if (episode is null) return Results.NotFound(); - - var ayah = await db.Ayahs.AsNoTracking() - .FirstOrDefaultAsync(a => a.SurahId == episode.SurahId && a.Number == episode.AyahNumber, ct); - - var detail = EpisodeMapper.ToDetail(episode, ayah) with + var bookmarks = new HashSet(); + var favorites = new HashSet(); + if (!string.IsNullOrWhiteSpace(deviceId)) { - AudioUrl = $"/api/audio/{episode.Id}" - }; - return Results.Ok(detail); - }); - - group.MapGet("/audio/{episodeId:guid}", async (Guid episodeId, AppDbContext db, IConfiguration config, IWebHostEnvironment env, CancellationToken ct) => - { - var episode = await db.Episodes.AsNoTracking().FirstOrDefaultAsync(e => e.Id == episodeId, ct); - if (episode is null) return Results.NotFound(); - - var audioRoot = config["AudioRoot"] ?? "/data/audio"; - var relative = episode.AudioUrl.TrimStart('/').Replace('\\', '/'); - - // Prefer PVC mount; fallback to wwwroot for local sample files - var candidates = new[] - { - Path.Combine(audioRoot, relative), - Path.Combine(env.ContentRootPath, "wwwroot", relative), - Path.Combine(env.ContentRootPath, "wwwroot", "audio", Path.GetFileName(relative)) - }; - - foreach (var path in candidates) - { - if (File.Exists(path)) - return Results.File(path, "audio/mpeg", enableRangeProcessing: true); + bookmarks = (await db.AyahBookmarks.AsNoTracking() + .Where(b => b.TafsirId == tafsir.Id && b.SurahId == surah.Id && b.DeviceId == deviceId) + .Select(b => b.AyahNumber).ToListAsync(ct)).ToHashSet(); + favorites = (await db.AyahFavorites.AsNoTracking() + .Where(f => f.TafsirId == tafsir.Id && f.SurahId == surah.Id && f.DeviceId == deviceId) + .Select(f => f.AyahNumber).ToListAsync(ct)).ToHashSet(); } - var silence = Path.Combine(env.ContentRootPath, "wwwroot", "audio", "silence.mp3"); - if (File.Exists(silence)) - return Results.File(silence, "audio/mpeg", enableRangeProcessing: true); + var contents = await db.TafsirAyahContents.AsNoTracking() + .Where(c => c.TafsirId == tafsir.Id && c.SurahId == surah.Id) + .ToDictionaryAsync(c => c.AyahNumber, ct); - return Results.NotFound(); + var ayahs = await db.Ayahs.AsNoTracking() + .Where(a => a.SurahId == surah.Id) + .OrderBy(a => a.Number) + .ToListAsync(ct); + + if (ayahs.Count == 0 && contents.Count > 0) + { + ayahs = contents.Keys.OrderBy(k => k).Select(n => new Domain.Entities.Ayah + { + SurahId = surah.Id, + Number = n, + TextFa = null + }).ToList(); + } + + var list = ayahs.Select(a => + { + contents.TryGetValue(a.Number, out var c); + var preview = a.TextFa; + if (!string.IsNullOrWhiteSpace(preview) && preview.Length > 80) + preview = preview[..80] + "…"; + return new AyahListItemDto( + surah.Number, + surah.NameFa, + a.Number, + preview, + !string.IsNullOrWhiteSpace(c?.AudioPath), + bookmarks.Contains(a.Number), + favorites.Contains(a.Number)); + }).ToList(); + + return Results.Ok(list); + }); + + group.MapGet("/tafsirs/{slug}/surahs/{surahNumber:int}/ayahs/{ayahNumber:int}", async ( + string slug, int surahNumber, int ayahNumber, AppDbContext db, CancellationToken ct) => + { + var tafsir = await db.Tafsirs.AsNoTracking().FirstOrDefaultAsync(t => t.Slug == slug && t.IsPublished, ct); + if (tafsir is null) return Results.NotFound(); + + var surah = await db.Surahs.AsNoTracking().FirstOrDefaultAsync(s => s.Number == surahNumber, ct); + if (surah is null) return Results.NotFound(); + + var ayah = await db.Ayahs.AsNoTracking() + .FirstOrDefaultAsync(a => a.SurahId == surah.Id && a.Number == ayahNumber, ct); + var content = await db.TafsirAyahContents.AsNoTracking() + .FirstOrDefaultAsync(c => c.TafsirId == tafsir.Id && c.SurahId == surah.Id && c.AyahNumber == ayahNumber, ct); + + if (ayah is null && content is null) return Results.NotFound(); + + var numbers = await db.Ayahs.AsNoTracking() + .Where(a => a.SurahId == surah.Id) + .Select(a => a.Number) + .OrderBy(n => n) + .ToListAsync(ct); + if (numbers.Count == 0) + { + numbers = await db.TafsirAyahContents.AsNoTracking() + .Where(c => c.TafsirId == tafsir.Id && c.SurahId == surah.Id) + .Select(c => c.AyahNumber) + .OrderBy(n => n) + .ToListAsync(ct); + } + + int? prev = null, next = null; + var idx = numbers.IndexOf(ayahNumber); + if (idx > 0) prev = numbers[idx - 1]; + if (idx >= 0 && idx < numbers.Count - 1) next = numbers[idx + 1]; + + int? prevSurah = surahNumber > 1 ? surahNumber - 1 : null; + int? nextSurah = surahNumber < 114 ? surahNumber + 1 : null; + + string? audioUrl = null; + if (!string.IsNullOrWhiteSpace(content?.AudioPath)) + audioUrl = $"/api/audio/ayah/{surahNumber}/{ayahNumber}?tafsirId={tafsir.Id}"; + + return Results.Ok(new AyahDetailDto( + tafsir.Id, + tafsir.Slug, + surah.Number, + surah.NameFa, + surah.NameAr, + ayahNumber, + ayah?.TextAr ?? "", + ayah?.TextFa, + content?.NotesHtml, + content?.MessagesHtml, + content?.VideoEmbedUrl, + audioUrl, + prev, + next, + prevSurah, + nextSurah)); + }); + + group.MapGet("/audio/ayah/{surahNumber:int}/{ayahNumber:int}", async ( + int surahNumber, int ayahNumber, Guid? tafsirId, AppDbContext db, IConfiguration config, CancellationToken ct) => + { + var tid = tafsirId ?? DbSeeder.GhatrehChekaniId; + var content = await db.TafsirAyahContents.AsNoTracking() + .FirstOrDefaultAsync(c => c.TafsirId == tid && c.SurahId == surahNumber && c.AyahNumber == ayahNumber, ct); + if (content is null || string.IsNullOrWhiteSpace(content.AudioPath)) + return Results.NotFound(); + + var audioRoot = config["AudioRoot"] ?? "/data/audio"; + var relative = content.AudioPath.TrimStart('/').Replace('\\', '/'); + var path = Path.Combine(audioRoot, relative); + if (!File.Exists(path)) + return Results.NotFound(); + + return Results.File(path, "audio/mpeg", enableRangeProcessing: true); }); return group; diff --git a/src/TafsirQuran.Domain/Entities/AppUser.cs b/src/TafsirQuran.Domain/Entities/AppUser.cs index 4a6e965..694203b 100644 --- a/src/TafsirQuran.Domain/Entities/AppUser.cs +++ b/src/TafsirQuran.Domain/Entities/AppUser.cs @@ -9,6 +9,7 @@ public class AppUser public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public ICollection ListeningProgresses { get; set; } = new List(); - public ICollection Favorites { get; set; } = new List(); - public ICollection Bookmarks { get; set; } = new List(); + public ICollection SurahBookmarks { get; set; } = new List(); + public ICollection AyahBookmarks { get; set; } = new List(); + public ICollection AyahFavorites { get; set; } = new List(); } diff --git a/src/TafsirQuran.Domain/Entities/AyahBookmark.cs b/src/TafsirQuran.Domain/Entities/AyahBookmark.cs new file mode 100644 index 0000000..bccbbe4 --- /dev/null +++ b/src/TafsirQuran.Domain/Entities/AyahBookmark.cs @@ -0,0 +1,17 @@ +namespace TafsirQuran.Domain.Entities; + +public class AyahBookmark +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid? UserId { get; set; } + public string DeviceId { get; set; } = string.Empty; + public Guid TafsirId { get; set; } + public int SurahId { get; set; } + public int AyahNumber { get; set; } + public string? Note { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public AppUser? User { get; set; } + public Tafsir Tafsir { get; set; } = null!; + public Surah Surah { get; set; } = null!; +} diff --git a/src/TafsirQuran.Domain/Entities/Favorite.cs b/src/TafsirQuran.Domain/Entities/AyahFavorite.cs similarity index 56% rename from src/TafsirQuran.Domain/Entities/Favorite.cs rename to src/TafsirQuran.Domain/Entities/AyahFavorite.cs index 3c8422c..72cc44d 100644 --- a/src/TafsirQuran.Domain/Entities/Favorite.cs +++ b/src/TafsirQuran.Domain/Entities/AyahFavorite.cs @@ -1,13 +1,16 @@ namespace TafsirQuran.Domain.Entities; -public class Favorite +public class AyahFavorite { public Guid Id { get; set; } = Guid.NewGuid(); public Guid? UserId { get; set; } public string DeviceId { get; set; } = string.Empty; - public Guid EpisodeId { get; set; } + public Guid TafsirId { get; set; } + public int SurahId { get; set; } + public int AyahNumber { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public AppUser? User { get; set; } - public TafsirEpisode Episode { get; set; } = null!; + public Tafsir Tafsir { get; set; } = null!; + public Surah Surah { get; set; } = null!; } diff --git a/src/TafsirQuran.Domain/Entities/ListeningProgress.cs b/src/TafsirQuran.Domain/Entities/ListeningProgress.cs index c40a1e4..f875234 100644 --- a/src/TafsirQuran.Domain/Entities/ListeningProgress.cs +++ b/src/TafsirQuran.Domain/Entities/ListeningProgress.cs @@ -5,10 +5,13 @@ public class ListeningProgress public Guid Id { get; set; } = Guid.NewGuid(); public Guid? UserId { get; set; } public string DeviceId { get; set; } = string.Empty; - public Guid EpisodeId { get; set; } + public Guid TafsirId { get; set; } + public int SurahId { get; set; } + public int AyahNumber { get; set; } public double PositionSeconds { get; set; } public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public AppUser? User { get; set; } - public TafsirEpisode Episode { get; set; } = null!; + public Tafsir Tafsir { get; set; } = null!; + public Surah Surah { get; set; } = null!; } diff --git a/src/TafsirQuran.Domain/Entities/Bookmark.cs b/src/TafsirQuran.Domain/Entities/SurahBookmark.cs similarity index 63% rename from src/TafsirQuran.Domain/Entities/Bookmark.cs rename to src/TafsirQuran.Domain/Entities/SurahBookmark.cs index 6bd47cb..1f7a787 100644 --- a/src/TafsirQuran.Domain/Entities/Bookmark.cs +++ b/src/TafsirQuran.Domain/Entities/SurahBookmark.cs @@ -1,14 +1,16 @@ namespace TafsirQuran.Domain.Entities; -public class Bookmark +public class SurahBookmark { public Guid Id { get; set; } = Guid.NewGuid(); public Guid? UserId { get; set; } public string DeviceId { get; set; } = string.Empty; - public Guid EpisodeId { get; set; } + public Guid TafsirId { get; set; } + public int SurahId { get; set; } public string? Note { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public AppUser? User { get; set; } - public TafsirEpisode Episode { get; set; } = null!; + public Tafsir Tafsir { get; set; } = null!; + public Surah Surah { get; set; } = null!; } diff --git a/src/TafsirQuran.Domain/Entities/Tafsir.cs b/src/TafsirQuran.Domain/Entities/Tafsir.cs index d9a3948..537cca1 100644 --- a/src/TafsirQuran.Domain/Entities/Tafsir.cs +++ b/src/TafsirQuran.Domain/Entities/Tafsir.cs @@ -10,4 +10,5 @@ public class Tafsir public bool IsPublished { get; set; } = true; public ICollection Episodes { get; set; } = new List(); + public ICollection AyahContents { get; set; } = new List(); } diff --git a/src/TafsirQuran.Domain/Entities/TafsirAyahContent.cs b/src/TafsirQuran.Domain/Entities/TafsirAyahContent.cs new file mode 100644 index 0000000..fae5fd5 --- /dev/null +++ b/src/TafsirQuran.Domain/Entities/TafsirAyahContent.cs @@ -0,0 +1,18 @@ +namespace TafsirQuran.Domain.Entities; + +public class TafsirAyahContent +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid TafsirId { get; set; } + public int SurahId { get; set; } + public int AyahNumber { get; set; } + public string? NotesHtml { get; set; } + public string? MessagesHtml { get; set; } + public string? VideoEmbedUrl { get; set; } + /// Relative path under AudioRoot, e.g. gharaati/1/2.mp3 + public string? AudioPath { get; set; } + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public Tafsir Tafsir { get; set; } = null!; + public Surah Surah { get; set; } = null!; +} diff --git a/src/TafsirQuran.Infrastructure/Persistence/AppDbContext.cs b/src/TafsirQuran.Infrastructure/Persistence/AppDbContext.cs index a3ac1a8..6aae296 100644 --- a/src/TafsirQuran.Infrastructure/Persistence/AppDbContext.cs +++ b/src/TafsirQuran.Infrastructure/Persistence/AppDbContext.cs @@ -10,9 +10,11 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet Surahs => Set(); public DbSet Ayahs => Set(); public DbSet Episodes => Set(); + public DbSet TafsirAyahContents => Set(); public DbSet ListeningProgresses => Set(); - public DbSet Favorites => Set(); - public DbSet Bookmarks => Set(); + public DbSet SurahBookmarks => Set(); + public DbSet AyahBookmarks => Set(); + public DbSet AyahFavorites => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -56,29 +58,51 @@ public class AppDbContext(DbContextOptions options) : DbContext(op e.HasOne(x => x.Surah).WithMany(x => x.Episodes).HasForeignKey(x => x.SurahId); }); + modelBuilder.Entity(e => + { + e.HasIndex(x => new { x.TafsirId, x.SurahId, x.AyahNumber }).IsUnique(); + e.Property(x => x.AudioPath).HasMaxLength(400); + e.Property(x => x.VideoEmbedUrl).HasMaxLength(500); + e.HasOne(x => x.Tafsir).WithMany(x => x.AyahContents).HasForeignKey(x => x.TafsirId); + e.HasOne(x => x.Surah).WithMany().HasForeignKey(x => x.SurahId); + }); + modelBuilder.Entity(e => { e.HasIndex(x => new { x.DeviceId, x.UserId }); e.Property(x => x.DeviceId).HasMaxLength(64); e.HasOne(x => x.User).WithMany(x => x.ListeningProgresses).HasForeignKey(x => x.UserId); - e.HasOne(x => x.Episode).WithMany().HasForeignKey(x => x.EpisodeId); + e.HasOne(x => x.Tafsir).WithMany().HasForeignKey(x => x.TafsirId); + e.HasOne(x => x.Surah).WithMany().HasForeignKey(x => x.SurahId); }); - modelBuilder.Entity(e => + modelBuilder.Entity(e => { - e.HasIndex(x => new { x.DeviceId, x.UserId, x.EpisodeId }).IsUnique(); - e.Property(x => x.DeviceId).HasMaxLength(64); - e.HasOne(x => x.User).WithMany(x => x.Favorites).HasForeignKey(x => x.UserId); - e.HasOne(x => x.Episode).WithMany().HasForeignKey(x => x.EpisodeId); - }); - - modelBuilder.Entity(e => - { - e.HasIndex(x => new { x.DeviceId, x.UserId, x.EpisodeId }).IsUnique(); + e.HasIndex(x => new { x.DeviceId, x.UserId, x.TafsirId, x.SurahId }).IsUnique(); e.Property(x => x.DeviceId).HasMaxLength(64); e.Property(x => x.Note).HasMaxLength(500); - e.HasOne(x => x.User).WithMany(x => x.Bookmarks).HasForeignKey(x => x.UserId); - e.HasOne(x => x.Episode).WithMany().HasForeignKey(x => x.EpisodeId); + e.HasOne(x => x.User).WithMany(x => x.SurahBookmarks).HasForeignKey(x => x.UserId); + e.HasOne(x => x.Tafsir).WithMany().HasForeignKey(x => x.TafsirId); + e.HasOne(x => x.Surah).WithMany().HasForeignKey(x => x.SurahId); + }); + + modelBuilder.Entity(e => + { + e.HasIndex(x => new { x.DeviceId, x.UserId, x.TafsirId, x.SurahId, x.AyahNumber }).IsUnique(); + e.Property(x => x.DeviceId).HasMaxLength(64); + e.Property(x => x.Note).HasMaxLength(500); + e.HasOne(x => x.User).WithMany(x => x.AyahBookmarks).HasForeignKey(x => x.UserId); + e.HasOne(x => x.Tafsir).WithMany().HasForeignKey(x => x.TafsirId); + e.HasOne(x => x.Surah).WithMany().HasForeignKey(x => x.SurahId); + }); + + modelBuilder.Entity(e => + { + e.HasIndex(x => new { x.DeviceId, x.UserId, x.TafsirId, x.SurahId, x.AyahNumber }).IsUnique(); + e.Property(x => x.DeviceId).HasMaxLength(64); + e.HasOne(x => x.User).WithMany(x => x.AyahFavorites).HasForeignKey(x => x.UserId); + e.HasOne(x => x.Tafsir).WithMany().HasForeignKey(x => x.TafsirId); + e.HasOne(x => x.Surah).WithMany().HasForeignKey(x => x.SurahId); }); } } diff --git a/src/TafsirQuran.Infrastructure/Persistence/DbSeeder.cs b/src/TafsirQuran.Infrastructure/Persistence/DbSeeder.cs index bd389e1..c92a0b8 100644 --- a/src/TafsirQuran.Infrastructure/Persistence/DbSeeder.cs +++ b/src/TafsirQuran.Infrastructure/Persistence/DbSeeder.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.EntityFrameworkCore; using TafsirQuran.Domain.Entities; @@ -40,6 +41,18 @@ public static class DbSeeder ("الفلق", "فلق"), ("الناس", "ناس") ]; + private sealed class GharaatiJson + { + public int Surah { get; set; } + public int Ayah { get; set; } + public string? TextAr { get; set; } + public string? TextFa { get; set; } + public string? NotesHtml { get; set; } + public string? MessagesHtml { get; set; } + public string? VideoEmbedUrl { get; set; } + public string? AudioPath { get; set; } + } + public static async Task SeedAsync(AppDbContext db, string? audioRoot = null, CancellationToken ct = default) { await db.Database.MigrateAsync(ct); @@ -77,94 +90,111 @@ public static class DbSeeder } db.ChangeTracker.Clear(); - await SyncEpisodesFromAudioAsync(db, audioRoot, ct); + await ImportGharaatiContentAsync(db, audioRoot, ct); } - public static async Task SyncEpisodesFromAudioAsync(AppDbContext db, string? audioRoot, CancellationToken ct = default) + public static async Task ImportGharaatiContentAsync(AppDbContext db, string? audioRoot, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(audioRoot) || !Directory.Exists(audioRoot)) - return; + if (string.IsNullOrWhiteSpace(audioRoot)) return; + var contentRoot = Path.Combine(audioRoot, "gharaati", "content"); + if (!Directory.Exists(contentRoot)) return; - if (!await db.Tafsirs.AsNoTracking().AnyAsync(t => t.Id == GhatrehChekaniId, ct)) - return; + var files = Directory.EnumerateFiles(contentRoot, "*.json", SearchOption.AllDirectories).OrderBy(f => f).ToList(); + if (files.Count == 0) return; - var existingSet = (await db.Episodes.AsNoTracking() - .Where(e => e.TafsirId == GhatrehChekaniId) - .Select(e => e.AudioUrl) + var existingAyahs = (await db.Ayahs.AsNoTracking() + .Select(a => new { a.SurahId, a.Number }) .ToListAsync(ct)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); + .Select(a => (a.SurahId, a.Number)) + .ToHashSet(); - var audioFiles = Directory.EnumerateFiles(audioRoot, "*.*", SearchOption.AllDirectories) - .Where(f => f.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) - || f.EndsWith(".m4a", StringComparison.OrdinalIgnoreCase) - || f.EndsWith(".ogg", StringComparison.OrdinalIgnoreCase) - || f.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) - .OrderBy(f => f, StringComparer.OrdinalIgnoreCase) - .ToList(); + // For updates we need tracked entities — reload after each batch clear + var trackedContent = await db.TafsirAyahContents + .Where(c => c.TafsirId == GhatrehChekaniId) + .ToDictionaryAsync(c => (c.SurahId, c.AyahNumber), ct); - if (audioFiles.Count == 0) return; - - var sort = existingSet.Count; - var added = 0; - foreach (var file in audioFiles) + var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var n = 0; + foreach (var file in files) { - var relative = Path.GetRelativePath(audioRoot, file).Replace('\\', '/'); - if (existingSet.Contains(relative)) continue; - - var surahNumber = GuessSurahNumber(relative, file); - var ayahNumber = GuessAyahNumber(Path.GetFileNameWithoutExtension(file)); - sort++; - db.Episodes.Add(new TafsirEpisode + GharaatiJson? item; + try { - Id = Guid.NewGuid(), - TafsirId = GhatrehChekaniId, - SurahId = surahNumber, - AyahNumber = ayahNumber, - Title = $"{SurahNames[surahNumber - 1].Fa} — {Path.GetFileNameWithoutExtension(file)}", - AudioUrl = relative, - DurationSeconds = 0, - SortOrder = sort, - Summary = null - }); - existingSet.Add(relative); - added++; + item = JsonSerializer.Deserialize(await File.ReadAllTextAsync(file, ct), opts); + } + catch + { + continue; + } - // batch inserts to avoid huge change tracker - if (added % 200 == 0) + if (item is null || item.Surah is < 1 or > 114 || item.Ayah < 1) + continue; + + if (!existingAyahs.Contains((item.Surah, item.Ayah))) + { + db.Ayahs.Add(new Ayah + { + SurahId = item.Surah, + Number = item.Ayah, + TextAr = item.TextAr ?? "", + TextFa = item.TextFa + }); + existingAyahs.Add((item.Surah, item.Ayah)); + } + else + { + var ayah = await db.Ayahs.FirstOrDefaultAsync(a => a.SurahId == item.Surah && a.Number == item.Ayah, ct); + if (ayah is not null) + { + if (!string.IsNullOrWhiteSpace(item.TextAr)) ayah.TextAr = item.TextAr!; + if (!string.IsNullOrWhiteSpace(item.TextFa)) ayah.TextFa = item.TextFa; + } + } + + var audioPath = item.AudioPath; + if (string.IsNullOrWhiteSpace(audioPath)) + { + var candidate = Path.Combine(audioRoot, "gharaati", item.Surah.ToString(), $"{item.Ayah}.mp3"); + if (File.Exists(candidate)) + audioPath = $"gharaati/{item.Surah}/{item.Ayah}.mp3"; + } + + if (trackedContent.TryGetValue((item.Surah, item.Ayah), out var content)) + { + content.NotesHtml = item.NotesHtml; + content.MessagesHtml = item.MessagesHtml; + content.VideoEmbedUrl = item.VideoEmbedUrl; + content.AudioPath = audioPath; + content.UpdatedAt = DateTimeOffset.UtcNow; + } + else + { + content = new TafsirAyahContent + { + TafsirId = GhatrehChekaniId, + SurahId = item.Surah, + AyahNumber = item.Ayah, + NotesHtml = item.NotesHtml, + MessagesHtml = item.MessagesHtml, + VideoEmbedUrl = item.VideoEmbedUrl, + AudioPath = audioPath + }; + db.TafsirAyahContents.Add(content); + trackedContent[(item.Surah, item.Ayah)] = content; + } + + n++; + if (n % 100 == 0) { await db.SaveChangesAsync(ct); db.ChangeTracker.Clear(); + trackedContent = await db.TafsirAyahContents + .Where(c => c.TafsirId == GhatrehChekaniId) + .ToDictionaryAsync(c => (c.SurahId, c.AyahNumber), ct); } } if (db.ChangeTracker.HasChanges()) await db.SaveChangesAsync(ct); } - - private static int GuessSurahNumber(string relative, string fullPath) - { - var parts = relative.Split('/', StringSplitOptions.RemoveEmptyEntries); - foreach (var part in parts) - { - var digits = new string(part.TakeWhile(char.IsDigit).ToArray()); - if (digits.Length >= 1 && int.TryParse(digits, out var n) && n is >= 1 and <= 114) - return n; - } - - var name = Path.GetFileName(fullPath); - var nameDigits = new string(name.TakeWhile(char.IsDigit).ToArray()); - if (nameDigits.Length >= 1 && int.TryParse(nameDigits, out var fromName) && fromName is >= 1 and <= 114) - return fromName; - - return 1; - } - - private static int GuessAyahNumber(string fileName) - { - var matches = System.Text.RegularExpressions.Regex.Matches(fileName, @"(\d+)"); - if (matches.Count == 0) return 1; - if (int.TryParse(matches[^1].Value, out var n) && n > 0) - return n; - return 1; - } } diff --git a/src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009205128_AyahBrowseAndGharaati.Designer.cs b/src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009205128_AyahBrowseAndGharaati.Designer.cs new file mode 100644 index 0000000..b9f2042 --- /dev/null +++ b/src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009205128_AyahBrowseAndGharaati.Designer.cs @@ -0,0 +1,576 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TafsirQuran.Infrastructure.Persistence; + +#nullable disable + +namespace TafsirQuran.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20261009205128_AyahBrowseAndGharaati")] + partial class AyahBrowseAndGharaati + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Ayah", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Number") + .HasColumnType("int"); + + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TextAr") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TextFa") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("SurahId", "Number") + .IsUnique(); + + b.ToTable("Ayahs"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AyahNumber") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TafsirId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SurahId"); + + b.HasIndex("TafsirId"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("AyahBookmarks"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahFavorite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AyahNumber") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TafsirId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SurahId"); + + b.HasIndex("TafsirId"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("AyahFavorites"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AyahNumber") + .HasColumnType("int"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("PositionSeconds") + .HasColumnType("float"); + + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TafsirId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SurahId"); + + b.HasIndex("TafsirId"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId"); + + b.ToTable("ListeningProgresses"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("NameAr") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("NameFa") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Number") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("Surahs"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.SurahBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TafsirId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SurahId"); + + b.HasIndex("TafsirId"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("SurahBookmarks"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b => + { + b.Property("Id") + .HasColumnType("uniqueidentifier"); + + b.Property("Author") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("CoverUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPublished") + .HasColumnType("bit"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tafsirs"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirAyahContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AudioPath") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("AyahNumber") + .HasColumnType("int"); + + b.Property("MessagesHtml") + .HasColumnType("nvarchar(max)"); + + b.Property("NotesHtml") + .HasColumnType("nvarchar(max)"); + + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TafsirId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VideoEmbedUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("SurahId"); + + b.HasIndex("TafsirId", "SurahId", "AyahNumber") + .IsUnique(); + + b.ToTable("TafsirAyahContents"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AudioUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AyahNumber") + .HasColumnType("int"); + + b.Property("DurationSeconds") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Summary") + .HasColumnType("nvarchar(max)"); + + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TafsirId") + .HasColumnType("uniqueidentifier"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("SurahId"); + + b.HasIndex("TafsirId", "SortOrder"); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Ayah", b => + { + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") + .WithMany("Ayahs") + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Surah"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahBookmark", b => + { + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") + .WithMany() + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir") + .WithMany() + .HasForeignKey("TafsirId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User") + .WithMany("AyahBookmarks") + .HasForeignKey("UserId"); + + b.Navigation("Surah"); + + b.Navigation("Tafsir"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahFavorite", b => + { + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") + .WithMany() + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir") + .WithMany() + .HasForeignKey("TafsirId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User") + .WithMany("AyahFavorites") + .HasForeignKey("UserId"); + + b.Navigation("Surah"); + + b.Navigation("Tafsir"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b => + { + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") + .WithMany() + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir") + .WithMany() + .HasForeignKey("TafsirId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User") + .WithMany("ListeningProgresses") + .HasForeignKey("UserId"); + + b.Navigation("Surah"); + + b.Navigation("Tafsir"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.SurahBookmark", b => + { + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") + .WithMany() + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir") + .WithMany() + .HasForeignKey("TafsirId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User") + .WithMany("SurahBookmarks") + .HasForeignKey("UserId"); + + b.Navigation("Surah"); + + b.Navigation("Tafsir"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirAyahContent", b => + { + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") + .WithMany() + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir") + .WithMany("AyahContents") + .HasForeignKey("TafsirId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Surah"); + + b.Navigation("Tafsir"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b => + { + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") + .WithMany("Episodes") + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir") + .WithMany("Episodes") + .HasForeignKey("TafsirId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Surah"); + + b.Navigation("Tafsir"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.AppUser", b => + { + b.Navigation("AyahBookmarks"); + + b.Navigation("AyahFavorites"); + + b.Navigation("ListeningProgresses"); + + b.Navigation("SurahBookmarks"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b => + { + b.Navigation("Ayahs"); + + b.Navigation("Episodes"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b => + { + b.Navigation("AyahContents"); + + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009205128_AyahBrowseAndGharaati.cs b/src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009205128_AyahBrowseAndGharaati.cs new file mode 100644 index 0000000..665d1d0 --- /dev/null +++ b/src/TafsirQuran.Infrastructure/Persistence/Migrations/20261009205128_AyahBrowseAndGharaati.cs @@ -0,0 +1,422 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TafsirQuran.Infrastructure.Persistence.Migrations +{ + /// + public partial class AyahBrowseAndGharaati : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ListeningProgresses_Episodes_EpisodeId", + table: "ListeningProgresses"); + + migrationBuilder.DropTable( + name: "Bookmarks"); + + migrationBuilder.DropTable( + name: "Favorites"); + + migrationBuilder.RenameColumn( + name: "EpisodeId", + table: "ListeningProgresses", + newName: "TafsirId"); + + migrationBuilder.RenameIndex( + name: "IX_ListeningProgresses_EpisodeId", + table: "ListeningProgresses", + newName: "IX_ListeningProgresses_TafsirId"); + + migrationBuilder.AddColumn( + name: "AyahNumber", + table: "ListeningProgresses", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "SurahId", + table: "ListeningProgresses", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "AyahBookmarks", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: true), + DeviceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + TafsirId = table.Column(type: "uniqueidentifier", nullable: false), + SurahId = table.Column(type: "int", nullable: false), + AyahNumber = table.Column(type: "int", nullable: false), + Note = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AyahBookmarks", x => x.Id); + table.ForeignKey( + name: "FK_AyahBookmarks_Surahs_SurahId", + column: x => x.SurahId, + principalTable: "Surahs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AyahBookmarks_Tafsirs_TafsirId", + column: x => x.TafsirId, + principalTable: "Tafsirs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AyahBookmarks_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "AyahFavorites", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: true), + DeviceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + TafsirId = table.Column(type: "uniqueidentifier", nullable: false), + SurahId = table.Column(type: "int", nullable: false), + AyahNumber = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AyahFavorites", x => x.Id); + table.ForeignKey( + name: "FK_AyahFavorites_Surahs_SurahId", + column: x => x.SurahId, + principalTable: "Surahs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AyahFavorites_Tafsirs_TafsirId", + column: x => x.TafsirId, + principalTable: "Tafsirs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AyahFavorites_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "SurahBookmarks", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: true), + DeviceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + TafsirId = table.Column(type: "uniqueidentifier", nullable: false), + SurahId = table.Column(type: "int", nullable: false), + Note = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_SurahBookmarks", x => x.Id); + table.ForeignKey( + name: "FK_SurahBookmarks_Surahs_SurahId", + column: x => x.SurahId, + principalTable: "Surahs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SurahBookmarks_Tafsirs_TafsirId", + column: x => x.TafsirId, + principalTable: "Tafsirs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_SurahBookmarks_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "TafsirAyahContents", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + TafsirId = table.Column(type: "uniqueidentifier", nullable: false), + SurahId = table.Column(type: "int", nullable: false), + AyahNumber = table.Column(type: "int", nullable: false), + NotesHtml = table.Column(type: "nvarchar(max)", nullable: true), + MessagesHtml = table.Column(type: "nvarchar(max)", nullable: true), + VideoEmbedUrl = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true), + AudioPath = table.Column(type: "nvarchar(400)", maxLength: 400, nullable: true), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TafsirAyahContents", x => x.Id); + table.ForeignKey( + name: "FK_TafsirAyahContents_Surahs_SurahId", + column: x => x.SurahId, + principalTable: "Surahs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TafsirAyahContents_Tafsirs_TafsirId", + column: x => x.TafsirId, + principalTable: "Tafsirs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ListeningProgresses_SurahId", + table: "ListeningProgresses", + column: "SurahId"); + + migrationBuilder.CreateIndex( + name: "IX_AyahBookmarks_DeviceId_UserId_TafsirId_SurahId_AyahNumber", + table: "AyahBookmarks", + columns: new[] { "DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber" }, + unique: true, + filter: "[UserId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AyahBookmarks_SurahId", + table: "AyahBookmarks", + column: "SurahId"); + + migrationBuilder.CreateIndex( + name: "IX_AyahBookmarks_TafsirId", + table: "AyahBookmarks", + column: "TafsirId"); + + migrationBuilder.CreateIndex( + name: "IX_AyahBookmarks_UserId", + table: "AyahBookmarks", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AyahFavorites_DeviceId_UserId_TafsirId_SurahId_AyahNumber", + table: "AyahFavorites", + columns: new[] { "DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber" }, + unique: true, + filter: "[UserId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AyahFavorites_SurahId", + table: "AyahFavorites", + column: "SurahId"); + + migrationBuilder.CreateIndex( + name: "IX_AyahFavorites_TafsirId", + table: "AyahFavorites", + column: "TafsirId"); + + migrationBuilder.CreateIndex( + name: "IX_AyahFavorites_UserId", + table: "AyahFavorites", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_SurahBookmarks_DeviceId_UserId_TafsirId_SurahId", + table: "SurahBookmarks", + columns: new[] { "DeviceId", "UserId", "TafsirId", "SurahId" }, + unique: true, + filter: "[UserId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_SurahBookmarks_SurahId", + table: "SurahBookmarks", + column: "SurahId"); + + migrationBuilder.CreateIndex( + name: "IX_SurahBookmarks_TafsirId", + table: "SurahBookmarks", + column: "TafsirId"); + + migrationBuilder.CreateIndex( + name: "IX_SurahBookmarks_UserId", + table: "SurahBookmarks", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_TafsirAyahContents_SurahId", + table: "TafsirAyahContents", + column: "SurahId"); + + migrationBuilder.CreateIndex( + name: "IX_TafsirAyahContents_TafsirId_SurahId_AyahNumber", + table: "TafsirAyahContents", + columns: new[] { "TafsirId", "SurahId", "AyahNumber" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_ListeningProgresses_Surahs_SurahId", + table: "ListeningProgresses", + column: "SurahId", + principalTable: "Surahs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_ListeningProgresses_Tafsirs_TafsirId", + table: "ListeningProgresses", + column: "TafsirId", + principalTable: "Tafsirs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ListeningProgresses_Surahs_SurahId", + table: "ListeningProgresses"); + + migrationBuilder.DropForeignKey( + name: "FK_ListeningProgresses_Tafsirs_TafsirId", + table: "ListeningProgresses"); + + migrationBuilder.DropTable( + name: "AyahBookmarks"); + + migrationBuilder.DropTable( + name: "AyahFavorites"); + + migrationBuilder.DropTable( + name: "SurahBookmarks"); + + migrationBuilder.DropTable( + name: "TafsirAyahContents"); + + migrationBuilder.DropIndex( + name: "IX_ListeningProgresses_SurahId", + table: "ListeningProgresses"); + + migrationBuilder.DropColumn( + name: "AyahNumber", + table: "ListeningProgresses"); + + migrationBuilder.DropColumn( + name: "SurahId", + table: "ListeningProgresses"); + + migrationBuilder.RenameColumn( + name: "TafsirId", + table: "ListeningProgresses", + newName: "EpisodeId"); + + migrationBuilder.RenameIndex( + name: "IX_ListeningProgresses_TafsirId", + table: "ListeningProgresses", + newName: "IX_ListeningProgresses_EpisodeId"); + + migrationBuilder.CreateTable( + name: "Bookmarks", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + EpisodeId = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + DeviceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Note = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Bookmarks", x => x.Id); + table.ForeignKey( + name: "FK_Bookmarks_Episodes_EpisodeId", + column: x => x.EpisodeId, + principalTable: "Episodes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Bookmarks_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "Favorites", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + EpisodeId = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: true), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + DeviceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Favorites", x => x.Id); + table.ForeignKey( + name: "FK_Favorites_Episodes_EpisodeId", + column: x => x.EpisodeId, + principalTable: "Episodes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Favorites_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_Bookmarks_DeviceId_UserId_EpisodeId", + table: "Bookmarks", + columns: new[] { "DeviceId", "UserId", "EpisodeId" }, + unique: true, + filter: "[UserId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Bookmarks_EpisodeId", + table: "Bookmarks", + column: "EpisodeId"); + + migrationBuilder.CreateIndex( + name: "IX_Bookmarks_UserId", + table: "Bookmarks", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Favorites_DeviceId_UserId_EpisodeId", + table: "Favorites", + columns: new[] { "DeviceId", "UserId", "EpisodeId" }, + unique: true, + filter: "[UserId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Favorites_EpisodeId", + table: "Favorites", + column: "EpisodeId"); + + migrationBuilder.CreateIndex( + name: "IX_Favorites_UserId", + table: "Favorites", + column: "UserId"); + + migrationBuilder.AddForeignKey( + name: "FK_ListeningProgresses_Episodes_EpisodeId", + table: "ListeningProgresses", + column: "EpisodeId", + principalTable: "Episodes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/src/TafsirQuran.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/src/TafsirQuran.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index 4def008..ae6c5cd 100644 --- a/src/TafsirQuran.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TafsirQuran.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -80,12 +80,15 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations b.ToTable("Ayahs"); }); - modelBuilder.Entity("TafsirQuran.Domain.Entities.Bookmark", b => + modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahBookmark", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("AyahNumber") + .HasColumnType("int"); + b.Property("CreatedAt") .HasColumnType("datetimeoffset"); @@ -94,35 +97,43 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); - b.Property("EpisodeId") - .HasColumnType("uniqueidentifier"); - b.Property("Note") .HasMaxLength(500) .HasColumnType("nvarchar(500)"); + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TafsirId") + .HasColumnType("uniqueidentifier"); + b.Property("UserId") .HasColumnType("uniqueidentifier"); b.HasKey("Id"); - b.HasIndex("EpisodeId"); + b.HasIndex("SurahId"); + + b.HasIndex("TafsirId"); b.HasIndex("UserId"); - b.HasIndex("DeviceId", "UserId", "EpisodeId") + b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber") .IsUnique() .HasFilter("[UserId] IS NOT NULL"); - b.ToTable("Bookmarks"); + b.ToTable("AyahBookmarks"); }); - modelBuilder.Entity("TafsirQuran.Domain.Entities.Favorite", b => + modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahFavorite", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("AyahNumber") + .HasColumnType("int"); + b.Property("CreatedAt") .HasColumnType("datetimeoffset"); @@ -131,7 +142,10 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); - b.Property("EpisodeId") + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TafsirId") .HasColumnType("uniqueidentifier"); b.Property("UserId") @@ -139,15 +153,17 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.HasIndex("EpisodeId"); + b.HasIndex("SurahId"); + + b.HasIndex("TafsirId"); b.HasIndex("UserId"); - b.HasIndex("DeviceId", "UserId", "EpisodeId") + b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber") .IsUnique() .HasFilter("[UserId] IS NOT NULL"); - b.ToTable("Favorites"); + b.ToTable("AyahFavorites"); }); modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b => @@ -156,17 +172,23 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations .ValueGeneratedOnAdd() .HasColumnType("uniqueidentifier"); + b.Property("AyahNumber") + .HasColumnType("int"); + b.Property("DeviceId") .IsRequired() .HasMaxLength(64) .HasColumnType("nvarchar(64)"); - b.Property("EpisodeId") - .HasColumnType("uniqueidentifier"); - b.Property("PositionSeconds") .HasColumnType("float"); + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TafsirId") + .HasColumnType("uniqueidentifier"); + b.Property("UpdatedAt") .HasColumnType("datetimeoffset"); @@ -175,7 +197,9 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.HasIndex("EpisodeId"); + b.HasIndex("SurahId"); + + b.HasIndex("TafsirId"); b.HasIndex("UserId"); @@ -210,6 +234,48 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations b.ToTable("Surahs"); }); + modelBuilder.Entity("TafsirQuran.Domain.Entities.SurahBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TafsirId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("SurahId"); + + b.HasIndex("TafsirId"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("SurahBookmarks"); + }); + modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b => { b.Property("Id") @@ -244,6 +310,48 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations b.ToTable("Tafsirs"); }); + modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirAyahContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AudioPath") + .HasMaxLength(400) + .HasColumnType("nvarchar(400)"); + + b.Property("AyahNumber") + .HasColumnType("int"); + + b.Property("MessagesHtml") + .HasColumnType("nvarchar(max)"); + + b.Property("NotesHtml") + .HasColumnType("nvarchar(max)"); + + b.Property("SurahId") + .HasColumnType("int"); + + b.Property("TafsirId") + .HasColumnType("uniqueidentifier"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VideoEmbedUrl") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("SurahId"); + + b.HasIndex("TafsirId", "SurahId", "AyahNumber") + .IsUnique(); + + b.ToTable("TafsirAyahContents"); + }); + modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b => { b.Property("Id") @@ -297,45 +405,67 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations b.Navigation("Surah"); }); - modelBuilder.Entity("TafsirQuran.Domain.Entities.Bookmark", b => + modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahBookmark", b => { - b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode") + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") .WithMany() - .HasForeignKey("EpisodeId") + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir") + .WithMany() + .HasForeignKey("TafsirId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User") - .WithMany("Bookmarks") + .WithMany("AyahBookmarks") .HasForeignKey("UserId"); - b.Navigation("Episode"); + b.Navigation("Surah"); + + b.Navigation("Tafsir"); b.Navigation("User"); }); - modelBuilder.Entity("TafsirQuran.Domain.Entities.Favorite", b => + modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahFavorite", b => { - b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode") + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") .WithMany() - .HasForeignKey("EpisodeId") + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir") + .WithMany() + .HasForeignKey("TafsirId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User") - .WithMany("Favorites") + .WithMany("AyahFavorites") .HasForeignKey("UserId"); - b.Navigation("Episode"); + b.Navigation("Surah"); + + b.Navigation("Tafsir"); b.Navigation("User"); }); modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b => { - b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode") + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") .WithMany() - .HasForeignKey("EpisodeId") + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir") + .WithMany() + .HasForeignKey("TafsirId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -343,11 +473,57 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations .WithMany("ListeningProgresses") .HasForeignKey("UserId"); - b.Navigation("Episode"); + b.Navigation("Surah"); + + b.Navigation("Tafsir"); b.Navigation("User"); }); + modelBuilder.Entity("TafsirQuran.Domain.Entities.SurahBookmark", b => + { + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") + .WithMany() + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir") + .WithMany() + .HasForeignKey("TafsirId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User") + .WithMany("SurahBookmarks") + .HasForeignKey("UserId"); + + b.Navigation("Surah"); + + b.Navigation("Tafsir"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirAyahContent", b => + { + b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") + .WithMany() + .HasForeignKey("SurahId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir") + .WithMany("AyahContents") + .HasForeignKey("TafsirId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Surah"); + + b.Navigation("Tafsir"); + }); + modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b => { b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah") @@ -369,11 +545,13 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations modelBuilder.Entity("TafsirQuran.Domain.Entities.AppUser", b => { - b.Navigation("Bookmarks"); + b.Navigation("AyahBookmarks"); - b.Navigation("Favorites"); + b.Navigation("AyahFavorites"); b.Navigation("ListeningProgresses"); + + b.Navigation("SurahBookmarks"); }); modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b => @@ -385,6 +563,8 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b => { + b.Navigation("AyahContents"); + b.Navigation("Episodes"); }); #pragma warning restore 612, 618 diff --git a/src/TafsirQuran.Shared/Dtos/TafsirDtos.cs b/src/TafsirQuran.Shared/Dtos/TafsirDtos.cs index 5c7e1c0..a82efa9 100644 --- a/src/TafsirQuran.Shared/Dtos/TafsirDtos.cs +++ b/src/TafsirQuran.Shared/Dtos/TafsirDtos.cs @@ -2,6 +2,93 @@ namespace TafsirQuran.Shared.Dtos; public record TafsirDto(Guid Id, string Title, string Author, string Slug, string? CoverUrl); +public record SurahListItemDto( + int Number, + string NameAr, + string NameFa, + int AyahCount, + bool IsBookmarked); + +public record AyahListItemDto( + int SurahNumber, + string SurahNameFa, + int AyahNumber, + string? TextFaPreview, + bool HasAudio, + bool IsBookmarked, + bool IsFavorite); + +public record AyahDetailDto( + Guid TafsirId, + string TafsirSlug, + int SurahNumber, + string SurahNameFa, + string SurahNameAr, + int AyahNumber, + string TextAr, + string? TextFa, + string? NotesHtml, + string? MessagesHtml, + string? VideoEmbedUrl, + string? AudioUrl, + int? PrevAyah, + int? NextAyah, + int? PrevSurah, + int? NextSurah); + +public record ProgressDto( + Guid TafsirId, + int SurahNumber, + int AyahNumber, + double PositionSeconds, + DateTimeOffset UpdatedAt, + string? SurahNameFa); + +public record UpsertProgressRequest( + Guid TafsirId, + int SurahNumber, + int AyahNumber, + double PositionSeconds, + string DeviceId); + +public record SurahBookmarkDto( + Guid Id, + Guid TafsirId, + int SurahNumber, + string SurahNameFa, + string? Note, + DateTimeOffset CreatedAt); + +public record AyahBookmarkDto( + Guid Id, + Guid TafsirId, + string TafsirSlug, + int SurahNumber, + string SurahNameFa, + int AyahNumber, + string? Note, + DateTimeOffset CreatedAt); + +public record AyahFavoriteDto( + Guid Id, + Guid TafsirId, + string TafsirSlug, + int SurahNumber, + string SurahNameFa, + int AyahNumber, + DateTimeOffset CreatedAt); + +public record ToggleSurahBookmarkRequest(Guid TafsirId, int SurahNumber, string DeviceId, string? Note = null); + +public record ToggleAyahRequest(Guid TafsirId, int SurahNumber, int AyahNumber, string DeviceId, string? Note = null); + +public record AuthRequest(string Email, string Password, string? DisplayName = null); + +public record AuthResponse(Guid UserId, string Email, string DisplayName, string Token); + +public record UserStatusDto(bool IsAuthenticated, Guid? UserId, string? Email, string? DisplayName); + +// Legacy episode DTOs kept for optional archive endpoints public record EpisodeListItemDto( Guid Id, string Title, @@ -24,19 +111,3 @@ public record EpisodeDetailDto( string AudioUrl, int DurationSeconds, string? Summary); - -public record ProgressDto(Guid EpisodeId, double PositionSeconds, DateTimeOffset UpdatedAt, EpisodeListItemDto? Episode); - -public record UpsertProgressRequest(Guid EpisodeId, double PositionSeconds, string DeviceId); - -public record FavoriteDto(Guid Id, Guid EpisodeId, DateTimeOffset CreatedAt, EpisodeListItemDto Episode); - -public record BookmarkDto(Guid Id, Guid EpisodeId, string? Note, DateTimeOffset CreatedAt, EpisodeListItemDto Episode); - -public record ToggleEpisodeRequest(Guid EpisodeId, string DeviceId, string? Note = null); - -public record AuthRequest(string Email, string Password, string? DisplayName = null); - -public record AuthResponse(Guid UserId, string Email, string DisplayName, string Token); - -public record UserStatusDto(bool IsAuthenticated, Guid? UserId, string? Email, string? DisplayName); diff --git a/src/TafsirQuran.Shared/Services/TafsirApiClient.cs b/src/TafsirQuran.Shared/Services/TafsirApiClient.cs index d64a24a..c589279 100644 --- a/src/TafsirQuran.Shared/Services/TafsirApiClient.cs +++ b/src/TafsirQuran.Shared/Services/TafsirApiClient.cs @@ -20,16 +20,25 @@ public class TafsirApiClient(HttpClient http, IAuthTokenStore tokenStore) return await http.GetFromJsonAsync>("api/tafsirs", ct) ?? []; } - public async Task> GetEpisodesAsync(string slug, CancellationToken ct = default) + public async Task> GetSurahsAsync(string deviceId, Guid? tafsirId = null, CancellationToken ct = default) { await EnsureAuthHeaderAsync(); - return await http.GetFromJsonAsync>($"api/tafsirs/{slug}/episodes", ct) ?? []; + var q = $"api/surahs?deviceId={Uri.EscapeDataString(deviceId)}"; + if (tafsirId is Guid id) q += $"&tafsirId={id}"; + return await http.GetFromJsonAsync>(q, ct) ?? []; } - public async Task GetEpisodeAsync(Guid id, CancellationToken ct = default) + public async Task> GetAyahsAsync(string slug, int surahNumber, string deviceId, CancellationToken ct = default) { await EnsureAuthHeaderAsync(); - return await http.GetFromJsonAsync($"api/episodes/{id}", ct); + var url = $"api/tafsirs/{slug}/surahs/{surahNumber}/ayahs?deviceId={Uri.EscapeDataString(deviceId)}"; + return await http.GetFromJsonAsync>(url, ct) ?? []; + } + + public async Task GetAyahAsync(string slug, int surahNumber, int ayahNumber, CancellationToken ct = default) + { + await EnsureAuthHeaderAsync(); + return await http.GetFromJsonAsync($"api/tafsirs/{slug}/surahs/{surahNumber}/ayahs/{ayahNumber}", ct); } public async Task GetProgressAsync(string deviceId, CancellationToken ct = default) @@ -49,43 +58,66 @@ public class TafsirApiClient(HttpClient http, IAuthTokenStore tokenStore) response.EnsureSuccessStatusCode(); } - public async Task> GetFavoritesAsync(string deviceId, CancellationToken ct = default) + public async Task> GetFavoritesAsync(string deviceId, CancellationToken ct = default) { await EnsureAuthHeaderAsync(); - return await http.GetFromJsonAsync>($"api/favorites?deviceId={Uri.EscapeDataString(deviceId)}", ct) ?? []; + return await http.GetFromJsonAsync>($"api/favorites/ayahs?deviceId={Uri.EscapeDataString(deviceId)}", ct) ?? []; } - public async Task AddFavoriteAsync(ToggleEpisodeRequest request, CancellationToken ct = default) + public async Task AddFavoriteAsync(ToggleAyahRequest request, CancellationToken ct = default) { await EnsureAuthHeaderAsync(); - var response = await http.PostAsJsonAsync("api/favorites", request, ct); + var response = await http.PostAsJsonAsync("api/favorites/ayahs", request, ct); response.EnsureSuccessStatusCode(); } - public async Task RemoveFavoriteAsync(Guid episodeId, string deviceId, CancellationToken ct = default) + public async Task RemoveFavoriteAsync(Guid tafsirId, int surahNumber, int ayahNumber, string deviceId, CancellationToken ct = default) { await EnsureAuthHeaderAsync(); - var response = await http.DeleteAsync($"api/favorites/{episodeId}?deviceId={Uri.EscapeDataString(deviceId)}", ct); + var url = $"api/favorites/ayahs/{surahNumber}/{ayahNumber}?tafsirId={tafsirId}&deviceId={Uri.EscapeDataString(deviceId)}"; + var response = await http.DeleteAsync(url, ct); response.EnsureSuccessStatusCode(); } - public async Task> GetBookmarksAsync(string deviceId, CancellationToken ct = default) + public async Task> GetSurahBookmarksAsync(string deviceId, CancellationToken ct = default) { await EnsureAuthHeaderAsync(); - return await http.GetFromJsonAsync>($"api/bookmarks?deviceId={Uri.EscapeDataString(deviceId)}", ct) ?? []; + return await http.GetFromJsonAsync>($"api/bookmarks/surahs?deviceId={Uri.EscapeDataString(deviceId)}", ct) ?? []; } - public async Task AddBookmarkAsync(ToggleEpisodeRequest request, CancellationToken ct = default) + public async Task AddSurahBookmarkAsync(ToggleSurahBookmarkRequest request, CancellationToken ct = default) { await EnsureAuthHeaderAsync(); - var response = await http.PostAsJsonAsync("api/bookmarks", request, ct); + var response = await http.PostAsJsonAsync("api/bookmarks/surahs", request, ct); response.EnsureSuccessStatusCode(); } - public async Task RemoveBookmarkAsync(Guid episodeId, string deviceId, CancellationToken ct = default) + public async Task RemoveSurahBookmarkAsync(Guid tafsirId, int surahNumber, string deviceId, CancellationToken ct = default) { await EnsureAuthHeaderAsync(); - var response = await http.DeleteAsync($"api/bookmarks/{episodeId}?deviceId={Uri.EscapeDataString(deviceId)}", ct); + var url = $"api/bookmarks/surahs/{surahNumber}?tafsirId={tafsirId}&deviceId={Uri.EscapeDataString(deviceId)}"; + var response = await http.DeleteAsync(url, ct); + response.EnsureSuccessStatusCode(); + } + + public async Task> GetAyahBookmarksAsync(string deviceId, CancellationToken ct = default) + { + await EnsureAuthHeaderAsync(); + return await http.GetFromJsonAsync>($"api/bookmarks/ayahs?deviceId={Uri.EscapeDataString(deviceId)}", ct) ?? []; + } + + public async Task AddAyahBookmarkAsync(ToggleAyahRequest request, CancellationToken ct = default) + { + await EnsureAuthHeaderAsync(); + var response = await http.PostAsJsonAsync("api/bookmarks/ayahs", request, ct); + response.EnsureSuccessStatusCode(); + } + + public async Task RemoveAyahBookmarkAsync(Guid tafsirId, int surahNumber, int ayahNumber, string deviceId, CancellationToken ct = default) + { + await EnsureAuthHeaderAsync(); + var url = $"api/bookmarks/ayahs/{surahNumber}/{ayahNumber}?tafsirId={tafsirId}&deviceId={Uri.EscapeDataString(deviceId)}"; + var response = await http.DeleteAsync(url, ct); response.EnsureSuccessStatusCode(); } diff --git a/src/TafsirQuran.UI/Components/AudioPlayer.razor b/src/TafsirQuran.UI/Components/AudioPlayer.razor index b6c65cb..28c17d7 100644 --- a/src/TafsirQuran.UI/Components/AudioPlayer.razor +++ b/src/TafsirQuran.UI/Components/AudioPlayer.razor @@ -3,29 +3,46 @@ @implements IAsyncDisposable
-
@Episode.Title
+
@Title
- - + @if (ShowFavorite) + { + + } + @if (ShowBookmark) + { + + }
- + @if (!string.IsNullOrWhiteSpace(AudioUrl)) + { + -
- - -
@Format(current) / @Format(duration)
-
+
+ + +
@Format(current) / @Format(duration)
+
+ } + else + { +

صوت این آیه در دسترس نیست

+ }
@code { - [Parameter, EditorRequired] public EpisodeDetailDto Episode { get; set; } = default!; + [Parameter, EditorRequired] public string Title { get; set; } = ""; + [Parameter] public string? AudioUrl { get; set; } + [Parameter] public string AudioKey { get; set; } = ""; [Parameter] public double InitialPosition { get; set; } [Parameter] public bool IsFavorite { get; set; } [Parameter] public bool IsBookmarked { get; set; } + [Parameter] public bool ShowFavorite { get; set; } = true; + [Parameter] public bool ShowBookmark { get; set; } = true; [Parameter] public EventCallback OnFavoriteToggle { get; set; } [Parameter] public EventCallback OnBookmarkToggle { get; set; } [Parameter] public EventCallback OnProgress { get; set; } @@ -36,21 +53,24 @@ private bool playing; private double current; private double duration; - private Guid boundEpisodeId; + private string boundKey = ""; - private string AudioSrc => ResolveAudioUrl(Episode.AudioUrl); + private string AudioSrc => ResolveAudioUrl(AudioUrl ?? ""); protected override async Task OnAfterRenderAsync(bool firstRender) { + if (string.IsNullOrWhiteSpace(AudioUrl)) return; + if (firstRender) { self = DotNetObjectReference.Create(this); await Js.InvokeVoidAsync("tqAudio.bind", audioEl, self); } - if (boundEpisodeId != Episode.Id) + var key = string.IsNullOrWhiteSpace(AudioKey) ? AudioSrc : AudioKey; + if (boundKey != key) { - boundEpisodeId = Episode.Id; + boundKey = key; playing = false; current = InitialPosition; await Js.InvokeVoidAsync("tqAudio.load", audioEl, AudioSrc, InitialPosition); @@ -59,6 +79,7 @@ private string ResolveAudioUrl(string url) { + if (string.IsNullOrWhiteSpace(url)) return ""; if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return url; return new Uri(new Uri(ApiOptions.BaseAddress), url.TrimStart('/')).ToString(); diff --git a/src/TafsirQuran.UI/Pages/BookmarksPage.razor b/src/TafsirQuran.UI/Pages/BookmarksPage.razor index d562ad3..d8d58b9 100644 --- a/src/TafsirQuran.UI/Pages/BookmarksPage.razor +++ b/src/TafsirQuran.UI/Pages/BookmarksPage.razor @@ -3,39 +3,68 @@ @inject IDeviceIdStore DeviceStore
-

نشان‌شده‌ها

+ + @if (loading) {

در حال بارگذاری...

} - else if (items.Count == 0) - { -

هنوز نشانی ذخیره نشده است.

- } else { -
    - @foreach (var item in items) - { -
  • - @item.Episode.Title - @item.Episode.SurahNameFa — آیه @item.Episode.AyahNumber -
  • - } -
+

سوره‌ها

+ @if (surahs.Count == 0) + { +

سوره نشان‌شده‌ای نیست

+ } + else + { +
+ @foreach (var b in surahs) + { + + @b.SurahNameFa + سوره @b.SurahNumber + + } +
+ } + +

آیه‌ها

+ @if (ayahs.Count == 0) + { +

آیه نشان‌شده‌ای نیست

+ } + else + { +
+ @foreach (var b in ayahs) + { + + @b.SurahNameFa — آیه @b.AyahNumber + + } +
+ } }
@code { - private List items = []; private bool loading = true; + private List surahs = []; + private List ayahs = []; protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) return; var deviceId = await DeviceStore.GetOrCreateAsync(); - try { items = (await Api.GetBookmarksAsync(deviceId)).ToList(); } - catch { items = []; } + try + { + surahs = (await Api.GetSurahBookmarksAsync(deviceId)).ToList(); + ayahs = (await Api.GetAyahBookmarksAsync(deviceId)).ToList(); + } + catch { /* ignore */ } finally { loading = false; diff --git a/src/TafsirQuran.UI/Pages/FavoritesPage.razor b/src/TafsirQuran.UI/Pages/FavoritesPage.razor index 271a823..53ba0b8 100644 --- a/src/TafsirQuran.UI/Pages/FavoritesPage.razor +++ b/src/TafsirQuran.UI/Pages/FavoritesPage.razor @@ -3,39 +3,41 @@ @inject IDeviceIdStore DeviceStore
-

علاقه‌مندی‌ها

+ + @if (loading) {

در حال بارگذاری...

} else if (items.Count == 0) { -

هنوز علاقه‌مندی‌ای ندارید.

+

آیه علاقه‌مندی ثبت نشده

} else { - + }
@code { - private List items = []; private bool loading = true; + private List items = []; protected override async Task OnAfterRenderAsync(bool firstRender) { if (!firstRender) return; var deviceId = await DeviceStore.GetOrCreateAsync(); try { items = (await Api.GetFavoritesAsync(deviceId)).ToList(); } - catch { items = []; } + catch { /* ignore */ } finally { loading = false; diff --git a/src/TafsirQuran.UI/Pages/TafsirPage.razor b/src/TafsirQuran.UI/Pages/TafsirPage.razor index c130b43..1436b19 100644 --- a/src/TafsirQuran.UI/Pages/TafsirPage.razor +++ b/src/TafsirQuran.UI/Pages/TafsirPage.razor @@ -1,71 +1,136 @@ @page "/tafsir/{Slug}" +@page "/tafsir/{Slug}/surah/{SurahNumber:int}" +@page "/tafsir/{Slug}/surah/{SurahNumber:int}/ayah/{AyahNumber:int}" @inject TafsirApiClient Api @inject IDeviceIdStore DeviceStore +@inject NavigationManager Nav
@if (loading) {

در حال بارگذاری...

} - else + else if (view == View.Surahs) { -
-
- @foreach (var ep in episodes) - { - + +
+ } +
+ } + else if (view == View.Ayahs) + { +

سوره @selectedSurah?.NameFa

+
+ @foreach (var a in ayahs) + { + + } +
+ } + else if (detail is not null) + { +
+
+ + + +
+ +
+

@detail.TextAr

+ @if (!string.IsNullOrWhiteSpace(detail.TextFa)) + { +

@detail.TextFa

}
-
- @if (detail is null) - { -

یک آیه را انتخاب کنید

- } - else - { -
-

@detail.AyahTextAr

- @if (!string.IsNullOrWhiteSpace(detail.AyahTextFa)) - { -

@detail.AyahTextFa

- } + + + @if (!string.IsNullOrWhiteSpace(detail.NotesHtml)) + { +
+ نکته‌ها +
@((MarkupString)detail.NotesHtml)
+
+ } + @if (!string.IsNullOrWhiteSpace(detail.MessagesHtml)) + { +
+ پیام‌ها +
@((MarkupString)detail.MessagesHtml)
+
+ } + @if (!string.IsNullOrWhiteSpace(detail.VideoEmbedUrl)) + { +
+ فیلم +
+
- - - } -
+ + }
}
@code { + private enum View { Surahs, Ayahs, Detail } + [Parameter] public string Slug { get; set; } = ""; + [Parameter] public int? SurahNumber { get; set; } + [Parameter] public int? AyahNumber { get; set; } private string title = "تفسیر"; private string author = ""; + private Guid tafsirId; private bool loading = true; - private string? loadedForSlug; - private List episodes = []; - private EpisodeListItemDto? selected; - private EpisodeDetailDto? detail; + private string? routeKey; + private View view = View.Surahs; + private List surahs = []; + private List ayahs = []; + private SurahListItemDto? selectedSurah; + private AyahDetailDto? detail; private double initialPosition; private bool isFavorite; private bool isBookmarked; @@ -74,31 +139,52 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { - if (firstRender) ready = true; - if (!ready || string.IsNullOrWhiteSpace(Slug) || loadedForSlug == Slug) return; + if (!firstRender) return; + ready = true; + deviceId = await DeviceStore.GetOrCreateAsync(); + await LoadRouteAsync(); + } + + protected override async Task OnParametersSetAsync() + { + if (!ready) return; + await LoadRouteAsync(); + } + + private async Task LoadRouteAsync() + { + var key = $"{Slug}|{SurahNumber}|{AyahNumber}"; + if (routeKey == key) return; + routeKey = key; loading = true; - loadedForSlug = Slug; - deviceId = await DeviceStore.GetOrCreateAsync(); + await InvokeAsync(StateHasChanged); try { + if (string.IsNullOrWhiteSpace(deviceId)) + deviceId = await DeviceStore.GetOrCreateAsync(); + var tafsirs = await Api.GetTafsirsAsync(); - var tafsir = tafsirs.FirstOrDefault(t => t.Slug == Slug); + var tafsir = tafsirs.FirstOrDefault(t => t.Slug == slug); title = tafsir?.Title ?? "تفسیر"; author = tafsir?.Author ?? ""; + tafsirId = tafsir?.Id ?? Guid.Empty; - episodes = (await Api.GetEpisodesAsync(Slug)).ToList(); - - var progress = await Api.GetProgressAsync(deviceId); - var startId = progress?.Episode?.Id; - if (startId is null || episodes.All(e => e.Id != startId)) - startId = episodes.FirstOrDefault()?.Id; - - if (startId is Guid id) + if (AyahNumber is int a && SurahNumber is int s) { - initialPosition = progress?.EpisodeId == id ? progress.PositionSeconds : 0; - await SelectAsync(id, keepPosition: true); + view = View.Detail; + await LoadDetail(s, a); + } + else if (SurahNumber is int sn) + { + view = View.Ayahs; + await LoadAyahs(sn); + } + else + { + view = View.Surahs; + surahs = (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList(); } } finally @@ -108,25 +194,61 @@ } } - private async Task SelectAsync(Guid id, bool keepPosition = false) + private string slug => Slug; + + private void BackToSurahs() => Nav.NavigateTo($"/tafsir/{Slug}"); + + private void OpenSurah(int n) => Nav.NavigateTo($"/tafsir/{Slug}/surah/{n}"); + + private void OpenAyah(int n) { - selected = episodes.FirstOrDefault(e => e.Id == id); - detail = await Api.GetEpisodeAsync(id); - if (!keepPosition) initialPosition = 0; + var s = SurahNumber ?? detail?.SurahNumber ?? selectedSurah?.Number ?? 1; + Nav.NavigateTo($"/tafsir/{Slug}/surah/{s}/ayah/{n}"); + } + + private async Task LoadAyahs(int surahNumber) + { + surahs = surahs.Count > 0 ? surahs : (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList(); + selectedSurah = surahs.FirstOrDefault(x => x.Number == surahNumber); + ayahs = (await Api.GetAyahsAsync(Slug, surahNumber, deviceId)).ToList(); + } + + private async Task LoadDetail(int surahNumber, int ayahNumber) + { + detail = await Api.GetAyahAsync(Slug, surahNumber, ayahNumber); + if (detail is null) return; + + var progress = await Api.GetProgressAsync(deviceId); + initialPosition = progress is not null + && progress.SurahNumber == surahNumber + && progress.AyahNumber == ayahNumber + ? progress.PositionSeconds + : 0; var favorites = await Api.GetFavoritesAsync(deviceId); - var bookmarks = await Api.GetBookmarksAsync(deviceId); - isFavorite = favorites.Any(f => f.EpisodeId == id); - isBookmarked = bookmarks.Any(b => b.EpisodeId == id); + var bookmarks = await Api.GetAyahBookmarksAsync(deviceId); + isFavorite = favorites.Any(f => f.TafsirId == detail.TafsirId && f.SurahNumber == surahNumber && f.AyahNumber == ayahNumber); + isBookmarked = bookmarks.Any(b => b.TafsirId == detail.TafsirId && b.SurahNumber == surahNumber && b.AyahNumber == ayahNumber); + tafsirId = detail.TafsirId; + } + + private async Task ToggleSurahBookmark(SurahListItemDto s) + { + if (tafsirId == Guid.Empty) return; + if (s.IsBookmarked) + await Api.RemoveSurahBookmarkAsync(tafsirId, s.Number, deviceId); + else + await Api.AddSurahBookmarkAsync(new ToggleSurahBookmarkRequest(tafsirId, s.Number, deviceId)); + surahs = (await Api.GetSurahsAsync(deviceId, tafsirId)).ToList(); } private async Task ToggleFavorite() { if (detail is null) return; if (isFavorite) - await Api.RemoveFavoriteAsync(detail.Id, deviceId); + await Api.RemoveFavoriteAsync(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId); else - await Api.AddFavoriteAsync(new ToggleEpisodeRequest(detail.Id, deviceId)); + await Api.AddFavoriteAsync(new ToggleAyahRequest(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId)); isFavorite = !isFavorite; } @@ -134,15 +256,16 @@ { if (detail is null) return; if (isBookmarked) - await Api.RemoveBookmarkAsync(detail.Id, deviceId); + await Api.RemoveAyahBookmarkAsync(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId); else - await Api.AddBookmarkAsync(new ToggleEpisodeRequest(detail.Id, deviceId)); + await Api.AddAyahBookmarkAsync(new ToggleAyahRequest(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId)); isBookmarked = !isBookmarked; } private async Task SaveProgress(double position) { if (detail is null) return; - await Api.SaveProgressAsync(new UpsertProgressRequest(detail.Id, position, deviceId)); + await Api.SaveProgressAsync(new UpsertProgressRequest( + detail.TafsirId, detail.SurahNumber, detail.AyahNumber, position, deviceId)); } } diff --git a/src/TafsirQuran.UI/wwwroot/css/app.css b/src/TafsirQuran.UI/wwwroot/css/app.css index f4dcb94..2f7f023 100644 --- a/src/TafsirQuran.UI/wwwroot/css/app.css +++ b/src/TafsirQuran.UI/wwwroot/css/app.css @@ -226,3 +226,49 @@ button, input { font: inherit; } .link-btn { margin-top: 0.85rem; border: 0; background: transparent; color: var(--brand-deep); cursor: pointer; } + +.surah-grid { display: grid; gap: 0.55rem; } +.surah-card { + display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; align-items: center; + border: 1px solid var(--line); background: var(--surface); border-radius: 16px; padding: 0.35rem; +} +.surah-main { + border: 0; background: transparent; cursor: pointer; text-align: right; + display: grid; grid-template-columns: auto 1fr auto; gap: 0.75rem; align-items: center; + padding: 0.65rem 0.75rem; width: 100%; +} +.surah-num { + width: 2.2rem; height: 2.2rem; border-radius: 10px; display: grid; place-items: center; + background: rgba(31,107,74,0.12); color: var(--brand-deep); font-weight: 700; font-size: 0.85rem; +} +.surah-names { display: flex; flex-direction: column; gap: 0.1rem; } +.ayah-list { display: flex; flex-direction: column; gap: 0.4rem; max-height: 70vh; overflow: auto; } +.ayah-row { + border: 1px solid var(--line); background: var(--surface); border-radius: 14px; + padding: 0.75rem 0.9rem; text-align: right; cursor: pointer; + display: grid; grid-template-columns: auto 1fr auto; gap: 0.7rem; align-items: center; +} +.ayah-row:hover { border-color: rgba(31,107,74,0.45); background: #fff; } +.ayah-num { font-weight: 700; color: var(--brand); } +.ayah-preview { color: var(--muted); font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ayah-flags { display: flex; gap: 0.25rem; } +.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } +.dot.fav { background: #c45c26; } +.dot.mark { background: var(--brand); } +.dot.audio { background: #3b6ea5; } +.section-title { margin: 1.25rem 0 0.6rem; font-size: 1.05rem; } +.linkish { border: 0; background: transparent; color: var(--brand-deep); cursor: pointer; padding: 0; } +.ayah-nav { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; } +.content-block { + margin-top: 1rem; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); padding: 0.85rem 1rem; +} +.content-block summary { cursor: pointer; font-weight: 600; margin-bottom: 0.5rem; } +.html-body { line-height: 1.95; color: var(--ink); } +.html-body p { margin: 0 0 0.65rem; } +.video-wrap { position: relative; aspect-ratio: 16 / 9; max-width: 520px; } +.video-wrap iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; border-radius: 12px; } +.list-row { + display: flex; flex-direction: column; gap: 0.2rem; + padding: 0.9rem 1rem; border-radius: 14px; background: var(--surface); border: 1px solid var(--line); +} +.chip:disabled { opacity: 0.45; cursor: default; }