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
+ {
+
+ }
+
+ آیهها
+ @if (ayahs.Count == 0)
+ {
+ آیه نشانشدهای نیست
+ }
+ else
+ {
+
+ }
}
@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)
- {
-