Add Surah/Ayah browse with Gharaati Tafsir Noor crawl.
build-and-deploy / deploy (push) Failing after 11m0s
build-and-deploy / deploy (push) Failing after 11m0s
Replace episode bookmarks with surah/ayah bookmarks and ayah favorites; import ayah text, notes, messages, and official per-ayah audio from gharaati.ir.
This commit is contained in:
@@ -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
|
||||||
Executable
+161
@@ -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"<br\s*/?>", "\n", html, flags=re.I)
|
||||||
|
text = re.sub(r"</p\s*>", "\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'<span class="post-title h-text">\s*{re.escape(title)}\s*</span>.*?'
|
||||||
|
rf'<td[^>]*class=[\'"]?(?:tafsir_ayeh|tafsir)[\'"]?[^>]*>(.*?)</td>'
|
||||||
|
)
|
||||||
|
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())
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TafsirQuran.Api.Auth;
|
using TafsirQuran.Api.Auth;
|
||||||
using TafsirQuran.Api.Mapping;
|
|
||||||
using TafsirQuran.Domain.Entities;
|
using TafsirQuran.Domain.Entities;
|
||||||
using TafsirQuran.Infrastructure.Persistence;
|
using TafsirQuran.Infrastructure.Persistence;
|
||||||
using TafsirQuran.Shared.Dtos;
|
using TafsirQuran.Shared.Dtos;
|
||||||
@@ -12,52 +11,106 @@ public static class BookmarkEndpoints
|
|||||||
{
|
{
|
||||||
public static RouteGroupBuilder MapBookmarkEndpoints(this WebApplication app)
|
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 userId = JwtTokenService.GetUserId(user);
|
||||||
var items = await db.Bookmarks.AsNoTracking()
|
var items = await db.SurahBookmarks.AsNoTracking()
|
||||||
.Include(b => b.Episode).ThenInclude(e => e.Surah)
|
.Include(b => b.Surah)
|
||||||
.Where(b => b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId))
|
.Where(b => b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId))
|
||||||
.OrderByDescending(b => b.CreatedAt)
|
.OrderByDescending(b => b.CreatedAt)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return Results.Ok(items.Select(b => new BookmarkDto(
|
return Results.Ok(items.Select(b => new SurahBookmarkDto(
|
||||||
b.Id, b.EpisodeId, b.Note, b.CreatedAt, EpisodeMapper.ToListItem(b.Episode))).ToList());
|
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 userId = JwtTokenService.GetUserId(user);
|
||||||
var exists = await db.Bookmarks.AnyAsync(b =>
|
var exists = await db.SurahBookmarks.AnyAsync(b =>
|
||||||
b.EpisodeId == request.EpisodeId &&
|
b.TafsirId == request.TafsirId &&
|
||||||
|
b.SurahId == request.SurahNumber &&
|
||||||
(b.DeviceId == request.DeviceId || (userId.HasValue && b.UserId == userId)), ct);
|
(b.DeviceId == request.DeviceId || (userId.HasValue && b.UserId == userId)), ct);
|
||||||
if (exists) return Results.NoContent();
|
if (exists) return Results.NoContent();
|
||||||
|
|
||||||
db.Bookmarks.Add(new Bookmark
|
db.SurahBookmarks.Add(new SurahBookmark
|
||||||
{
|
{
|
||||||
DeviceId = request.DeviceId,
|
DeviceId = request.DeviceId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
EpisodeId = request.EpisodeId,
|
TafsirId = request.TafsirId,
|
||||||
|
SurahId = request.SurahNumber,
|
||||||
Note = request.Note
|
Note = request.Note
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync(ct);
|
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 userId = JwtTokenService.GetUserId(user);
|
||||||
var items = await db.Bookmarks
|
var items = await db.SurahBookmarks
|
||||||
.Where(b => b.EpisodeId == episodeId &&
|
.Where(b => b.TafsirId == tafsirId && b.SurahId == surahNumber &&
|
||||||
(b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId)))
|
(b.DeviceId == deviceId || (userId.HasValue && b.UserId == userId)))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
db.Bookmarks.RemoveRange(items);
|
db.SurahBookmarks.RemoveRange(items);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return Results.NoContent();
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TafsirQuran.Api.Auth;
|
using TafsirQuran.Api.Auth;
|
||||||
using TafsirQuran.Api.Mapping;
|
|
||||||
using TafsirQuran.Domain.Entities;
|
using TafsirQuran.Domain.Entities;
|
||||||
using TafsirQuran.Infrastructure.Persistence;
|
using TafsirQuran.Infrastructure.Persistence;
|
||||||
using TafsirQuran.Shared.Dtos;
|
using TafsirQuran.Shared.Dtos;
|
||||||
@@ -12,47 +11,53 @@ public static class FavoriteEndpoints
|
|||||||
{
|
{
|
||||||
public static RouteGroupBuilder MapFavoriteEndpoints(this WebApplication app)
|
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) =>
|
group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = JwtTokenService.GetUserId(user);
|
var userId = JwtTokenService.GetUserId(user);
|
||||||
var items = await db.Favorites.AsNoTracking()
|
var items = await db.AyahFavorites.AsNoTracking()
|
||||||
.Include(f => f.Episode).ThenInclude(e => e.Surah)
|
.Include(f => f.Surah)
|
||||||
|
.Include(f => f.Tafsir)
|
||||||
.Where(f => f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId))
|
.Where(f => f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId))
|
||||||
.OrderByDescending(f => f.CreatedAt)
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return Results.Ok(items.Select(f => new FavoriteDto(
|
return Results.Ok(items.Select(f => new AyahFavoriteDto(
|
||||||
f.Id, f.EpisodeId, f.CreatedAt, EpisodeMapper.ToListItem(f.Episode))).ToList());
|
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 userId = JwtTokenService.GetUserId(user);
|
||||||
var exists = await db.Favorites.AnyAsync(f =>
|
var exists = await db.AyahFavorites.AnyAsync(f =>
|
||||||
f.EpisodeId == request.EpisodeId &&
|
f.TafsirId == request.TafsirId &&
|
||||||
|
f.SurahId == request.SurahNumber &&
|
||||||
|
f.AyahNumber == request.AyahNumber &&
|
||||||
(f.DeviceId == request.DeviceId || (userId.HasValue && f.UserId == userId)), ct);
|
(f.DeviceId == request.DeviceId || (userId.HasValue && f.UserId == userId)), ct);
|
||||||
if (exists) return Results.NoContent();
|
if (exists) return Results.NoContent();
|
||||||
|
|
||||||
db.Favorites.Add(new Favorite
|
db.AyahFavorites.Add(new AyahFavorite
|
||||||
{
|
{
|
||||||
DeviceId = request.DeviceId,
|
DeviceId = request.DeviceId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
EpisodeId = request.EpisodeId
|
TafsirId = request.TafsirId,
|
||||||
|
SurahId = request.SurahNumber,
|
||||||
|
AyahNumber = request.AyahNumber
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync(ct);
|
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 userId = JwtTokenService.GetUserId(user);
|
||||||
var items = await db.Favorites
|
var items = await db.AyahFavorites
|
||||||
.Where(f => f.EpisodeId == episodeId &&
|
.Where(f => f.TafsirId == tafsirId && f.SurahId == surahNumber && f.AyahNumber == ayahNumber &&
|
||||||
(f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId)))
|
(f.DeviceId == deviceId || (userId.HasValue && f.UserId == userId)))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
db.Favorites.RemoveRange(items);
|
db.AyahFavorites.RemoveRange(items);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TafsirQuran.Api.Auth;
|
using TafsirQuran.Api.Auth;
|
||||||
using TafsirQuran.Api.Mapping;
|
|
||||||
using TafsirQuran.Domain.Entities;
|
using TafsirQuran.Domain.Entities;
|
||||||
using TafsirQuran.Infrastructure.Persistence;
|
using TafsirQuran.Infrastructure.Persistence;
|
||||||
using TafsirQuran.Shared.Dtos;
|
using TafsirQuran.Shared.Dtos;
|
||||||
@@ -17,22 +16,22 @@ public static class ProgressEndpoints
|
|||||||
group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
|
group.MapGet("/", async (string deviceId, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = JwtTokenService.GetUserId(user);
|
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;
|
ListeningProgress? progress = null;
|
||||||
if (userId.HasValue)
|
if (userId.HasValue)
|
||||||
{
|
|
||||||
progress = await query.Where(p => p.UserId == userId).OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync(ct);
|
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);
|
progress ??= await query.Where(p => p.DeviceId == deviceId).OrderByDescending(p => p.UpdatedAt).FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
if (progress is null) return Results.NoContent();
|
if (progress is null) return Results.NoContent();
|
||||||
|
|
||||||
return Results.Ok(new ProgressDto(
|
return Results.Ok(new ProgressDto(
|
||||||
progress.EpisodeId,
|
progress.TafsirId,
|
||||||
|
progress.SurahId,
|
||||||
|
progress.AyahNumber,
|
||||||
progress.PositionSeconds,
|
progress.PositionSeconds,
|
||||||
progress.UpdatedAt,
|
progress.UpdatedAt,
|
||||||
EpisodeMapper.ToListItem(progress.Episode)));
|
progress.Surah.NameFa));
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("/", async (UpsertProgressRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
|
group.MapPut("/", async (UpsertProgressRequest request, ClaimsPrincipal user, AppDbContext db, CancellationToken ct) =>
|
||||||
@@ -52,14 +51,18 @@ public static class ProgressEndpoints
|
|||||||
{
|
{
|
||||||
DeviceId = request.DeviceId,
|
DeviceId = request.DeviceId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
EpisodeId = request.EpisodeId,
|
TafsirId = request.TafsirId,
|
||||||
|
SurahId = request.SurahNumber,
|
||||||
|
AyahNumber = request.AyahNumber,
|
||||||
PositionSeconds = request.PositionSeconds
|
PositionSeconds = request.PositionSeconds
|
||||||
};
|
};
|
||||||
db.ListeningProgresses.Add(existing);
|
db.ListeningProgresses.Add(existing);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
existing.EpisodeId = request.EpisodeId;
|
existing.TafsirId = request.TafsirId;
|
||||||
|
existing.SurahId = request.SurahNumber;
|
||||||
|
existing.AyahNumber = request.AyahNumber;
|
||||||
existing.PositionSeconds = request.PositionSeconds;
|
existing.PositionSeconds = request.PositionSeconds;
|
||||||
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
existing.UserId ??= userId;
|
existing.UserId ??= userId;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TafsirQuran.Api.Mapping;
|
|
||||||
using TafsirQuran.Infrastructure.Persistence;
|
using TafsirQuran.Infrastructure.Persistence;
|
||||||
using TafsirQuran.Shared.Dtos;
|
using TafsirQuran.Shared.Dtos;
|
||||||
|
|
||||||
@@ -22,67 +21,178 @@ public static class TafsirEndpoints
|
|||||||
return Results.Ok(items);
|
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<int>();
|
||||||
|
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);
|
var tafsir = await db.Tafsirs.AsNoTracking().FirstOrDefaultAsync(t => t.Slug == slug && t.IsPublished, ct);
|
||||||
if (tafsir is null) return Results.NotFound();
|
if (tafsir is null) return Results.NotFound();
|
||||||
|
|
||||||
var episodes = await db.Episodes
|
var surah = await db.Surahs.AsNoTracking().FirstOrDefaultAsync(s => s.Number == surahNumber, ct);
|
||||||
.AsNoTracking()
|
if (surah is null) return Results.NotFound();
|
||||||
.Include(e => e.Surah)
|
|
||||||
.Where(e => e.TafsirId == tafsir.Id)
|
|
||||||
.OrderBy(e => e.SortOrder)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
return Results.Ok(episodes.Select(e => EpisodeMapper.ToListItem(e)).ToList());
|
var bookmarks = new HashSet<int>();
|
||||||
});
|
var favorites = new HashSet<int>();
|
||||||
|
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||||
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
|
|
||||||
{
|
{
|
||||||
AudioUrl = $"/api/audio/{episode.Id}"
|
bookmarks = (await db.AyahBookmarks.AsNoTracking()
|
||||||
};
|
.Where(b => b.TafsirId == tafsir.Id && b.SurahId == surah.Id && b.DeviceId == deviceId)
|
||||||
return Results.Ok(detail);
|
.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)
|
||||||
group.MapGet("/audio/{episodeId:guid}", async (Guid episodeId, AppDbContext db, IConfiguration config, IWebHostEnvironment env, CancellationToken ct) =>
|
.Select(f => f.AyahNumber).ToListAsync(ct)).ToHashSet();
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var silence = Path.Combine(env.ContentRootPath, "wwwroot", "audio", "silence.mp3");
|
var contents = await db.TafsirAyahContents.AsNoTracking()
|
||||||
if (File.Exists(silence))
|
.Where(c => c.TafsirId == tafsir.Id && c.SurahId == surah.Id)
|
||||||
return Results.File(silence, "audio/mpeg", enableRangeProcessing: true);
|
.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;
|
return group;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public class AppUser
|
|||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public ICollection<ListeningProgress> ListeningProgresses { get; set; } = new List<ListeningProgress>();
|
public ICollection<ListeningProgress> ListeningProgresses { get; set; } = new List<ListeningProgress>();
|
||||||
public ICollection<Favorite> Favorites { get; set; } = new List<Favorite>();
|
public ICollection<SurahBookmark> SurahBookmarks { get; set; } = new List<SurahBookmark>();
|
||||||
public ICollection<Bookmark> Bookmarks { get; set; } = new List<Bookmark>();
|
public ICollection<AyahBookmark> AyahBookmarks { get; set; } = new List<AyahBookmark>();
|
||||||
|
public ICollection<AyahFavorite> AyahFavorites { get; set; } = new List<AyahFavorite>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!;
|
||||||
|
}
|
||||||
+6
-3
@@ -1,13 +1,16 @@
|
|||||||
namespace TafsirQuran.Domain.Entities;
|
namespace TafsirQuran.Domain.Entities;
|
||||||
|
|
||||||
public class Favorite
|
public class AyahFavorite
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public Guid? UserId { get; set; }
|
public Guid? UserId { get; set; }
|
||||||
public string DeviceId { get; set; } = string.Empty;
|
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 DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public AppUser? User { get; set; }
|
public AppUser? User { get; set; }
|
||||||
public TafsirEpisode Episode { get; set; } = null!;
|
public Tafsir Tafsir { get; set; } = null!;
|
||||||
|
public Surah Surah { get; set; } = null!;
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,13 @@ public class ListeningProgress
|
|||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public Guid? UserId { get; set; }
|
public Guid? UserId { get; set; }
|
||||||
public string DeviceId { get; set; } = string.Empty;
|
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 double PositionSeconds { get; set; }
|
||||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public AppUser? User { get; set; }
|
public AppUser? User { get; set; }
|
||||||
public TafsirEpisode Episode { get; set; } = null!;
|
public Tafsir Tafsir { get; set; } = null!;
|
||||||
|
public Surah Surah { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -1,14 +1,16 @@
|
|||||||
namespace TafsirQuran.Domain.Entities;
|
namespace TafsirQuran.Domain.Entities;
|
||||||
|
|
||||||
public class Bookmark
|
public class SurahBookmark
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public Guid? UserId { get; set; }
|
public Guid? UserId { get; set; }
|
||||||
public string DeviceId { get; set; } = string.Empty;
|
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 string? Note { get; set; }
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public AppUser? User { get; set; }
|
public AppUser? User { get; set; }
|
||||||
public TafsirEpisode Episode { get; set; } = null!;
|
public Tafsir Tafsir { get; set; } = null!;
|
||||||
|
public Surah Surah { get; set; } = null!;
|
||||||
}
|
}
|
||||||
@@ -10,4 +10,5 @@ public class Tafsir
|
|||||||
public bool IsPublished { get; set; } = true;
|
public bool IsPublished { get; set; } = true;
|
||||||
|
|
||||||
public ICollection<TafsirEpisode> Episodes { get; set; } = new List<TafsirEpisode>();
|
public ICollection<TafsirEpisode> Episodes { get; set; } = new List<TafsirEpisode>();
|
||||||
|
public ICollection<TafsirAyahContent> AyahContents { get; set; } = new List<TafsirAyahContent>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
/// <summary>Relative path under AudioRoot, e.g. gharaati/1/2.mp3</summary>
|
||||||
|
public string? AudioPath { get; set; }
|
||||||
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public Tafsir Tafsir { get; set; } = null!;
|
||||||
|
public Surah Surah { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -10,9 +10,11 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
public DbSet<Surah> Surahs => Set<Surah>();
|
public DbSet<Surah> Surahs => Set<Surah>();
|
||||||
public DbSet<Ayah> Ayahs => Set<Ayah>();
|
public DbSet<Ayah> Ayahs => Set<Ayah>();
|
||||||
public DbSet<TafsirEpisode> Episodes => Set<TafsirEpisode>();
|
public DbSet<TafsirEpisode> Episodes => Set<TafsirEpisode>();
|
||||||
|
public DbSet<TafsirAyahContent> TafsirAyahContents => Set<TafsirAyahContent>();
|
||||||
public DbSet<ListeningProgress> ListeningProgresses => Set<ListeningProgress>();
|
public DbSet<ListeningProgress> ListeningProgresses => Set<ListeningProgress>();
|
||||||
public DbSet<Favorite> Favorites => Set<Favorite>();
|
public DbSet<SurahBookmark> SurahBookmarks => Set<SurahBookmark>();
|
||||||
public DbSet<Bookmark> Bookmarks => Set<Bookmark>();
|
public DbSet<AyahBookmark> AyahBookmarks => Set<AyahBookmark>();
|
||||||
|
public DbSet<AyahFavorite> AyahFavorites => Set<AyahFavorite>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -56,29 +58,51 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
e.HasOne(x => x.Surah).WithMany(x => x.Episodes).HasForeignKey(x => x.SurahId);
|
e.HasOne(x => x.Surah).WithMany(x => x.Episodes).HasForeignKey(x => x.SurahId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<TafsirAyahContent>(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<ListeningProgress>(e =>
|
modelBuilder.Entity<ListeningProgress>(e =>
|
||||||
{
|
{
|
||||||
e.HasIndex(x => new { x.DeviceId, x.UserId });
|
e.HasIndex(x => new { x.DeviceId, x.UserId });
|
||||||
e.Property(x => x.DeviceId).HasMaxLength(64);
|
e.Property(x => x.DeviceId).HasMaxLength(64);
|
||||||
e.HasOne(x => x.User).WithMany(x => x.ListeningProgresses).HasForeignKey(x => x.UserId);
|
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<Favorite>(e =>
|
modelBuilder.Entity<SurahBookmark>(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.HasOne(x => x.User).WithMany(x => x.Favorites).HasForeignKey(x => x.UserId);
|
|
||||||
e.HasOne(x => x.Episode).WithMany().HasForeignKey(x => x.EpisodeId);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<Bookmark>(e =>
|
|
||||||
{
|
|
||||||
e.HasIndex(x => new { x.DeviceId, x.UserId, x.EpisodeId }).IsUnique();
|
|
||||||
e.Property(x => x.DeviceId).HasMaxLength(64);
|
e.Property(x => x.DeviceId).HasMaxLength(64);
|
||||||
e.Property(x => x.Note).HasMaxLength(500);
|
e.Property(x => x.Note).HasMaxLength(500);
|
||||||
e.HasOne(x => x.User).WithMany(x => x.Bookmarks).HasForeignKey(x => x.UserId);
|
e.HasOne(x => x.User).WithMany(x => x.SurahBookmarks).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<AyahBookmark>(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<AyahFavorite>(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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TafsirQuran.Domain.Entities;
|
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)
|
public static async Task SeedAsync(AppDbContext db, string? audioRoot = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await db.Database.MigrateAsync(ct);
|
await db.Database.MigrateAsync(ct);
|
||||||
@@ -77,94 +90,111 @@ public static class DbSeeder
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.ChangeTracker.Clear();
|
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))
|
if (string.IsNullOrWhiteSpace(audioRoot)) return;
|
||||||
return;
|
var contentRoot = Path.Combine(audioRoot, "gharaati", "content");
|
||||||
|
if (!Directory.Exists(contentRoot)) return;
|
||||||
|
|
||||||
if (!await db.Tafsirs.AsNoTracking().AnyAsync(t => t.Id == GhatrehChekaniId, ct))
|
var files = Directory.EnumerateFiles(contentRoot, "*.json", SearchOption.AllDirectories).OrderBy(f => f).ToList();
|
||||||
return;
|
if (files.Count == 0) return;
|
||||||
|
|
||||||
var existingSet = (await db.Episodes.AsNoTracking()
|
var existingAyahs = (await db.Ayahs.AsNoTracking()
|
||||||
.Where(e => e.TafsirId == GhatrehChekaniId)
|
.Select(a => new { a.SurahId, a.Number })
|
||||||
.Select(e => e.AudioUrl)
|
|
||||||
.ToListAsync(ct))
|
.ToListAsync(ct))
|
||||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
.Select(a => (a.SurahId, a.Number))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
var audioFiles = Directory.EnumerateFiles(audioRoot, "*.*", SearchOption.AllDirectories)
|
// For updates we need tracked entities — reload after each batch clear
|
||||||
.Where(f => f.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase)
|
var trackedContent = await db.TafsirAyahContents
|
||||||
|| f.EndsWith(".m4a", StringComparison.OrdinalIgnoreCase)
|
.Where(c => c.TafsirId == GhatrehChekaniId)
|
||||||
|| f.EndsWith(".ogg", StringComparison.OrdinalIgnoreCase)
|
.ToDictionaryAsync(c => (c.SurahId, c.AyahNumber), ct);
|
||||||
|| f.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (audioFiles.Count == 0) return;
|
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
|
var n = 0;
|
||||||
var sort = existingSet.Count;
|
foreach (var file in files)
|
||||||
var added = 0;
|
|
||||||
foreach (var file in audioFiles)
|
|
||||||
{
|
{
|
||||||
var relative = Path.GetRelativePath(audioRoot, file).Replace('\\', '/');
|
GharaatiJson? item;
|
||||||
if (existingSet.Contains(relative)) continue;
|
try
|
||||||
|
|
||||||
var surahNumber = GuessSurahNumber(relative, file);
|
|
||||||
var ayahNumber = GuessAyahNumber(Path.GetFileNameWithoutExtension(file));
|
|
||||||
sort++;
|
|
||||||
db.Episodes.Add(new TafsirEpisode
|
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
item = JsonSerializer.Deserialize<GharaatiJson>(await File.ReadAllTextAsync(file, ct), opts);
|
||||||
TafsirId = GhatrehChekaniId,
|
}
|
||||||
SurahId = surahNumber,
|
catch
|
||||||
AyahNumber = ayahNumber,
|
{
|
||||||
Title = $"{SurahNames[surahNumber - 1].Fa} — {Path.GetFileNameWithoutExtension(file)}",
|
continue;
|
||||||
AudioUrl = relative,
|
}
|
||||||
DurationSeconds = 0,
|
|
||||||
SortOrder = sort,
|
|
||||||
Summary = null
|
|
||||||
});
|
|
||||||
existingSet.Add(relative);
|
|
||||||
added++;
|
|
||||||
|
|
||||||
// batch inserts to avoid huge change tracker
|
if (item is null || item.Surah is < 1 or > 114 || item.Ayah < 1)
|
||||||
if (added % 200 == 0)
|
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);
|
await db.SaveChangesAsync(ct);
|
||||||
db.ChangeTracker.Clear();
|
db.ChangeTracker.Clear();
|
||||||
|
trackedContent = await db.TafsirAyahContents
|
||||||
|
.Where(c => c.TafsirId == GhatrehChekaniId)
|
||||||
|
.ToDictionaryAsync(c => (c.SurahId, c.AyahNumber), ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (db.ChangeTracker.HasChanges())
|
if (db.ChangeTracker.HasChanges())
|
||||||
await db.SaveChangesAsync(ct);
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+576
@@ -0,0 +1,576 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Email")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.Ayah", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("Number")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("SurahId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("TextAr")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("TextFa")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SurahId", "Number")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Ayahs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahBookmark", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("AyahNumber")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("SurahId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TafsirId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("AyahNumber")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<int>("SurahId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TafsirId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid?>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("AyahNumber")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<double>("PositionSeconds")
|
||||||
|
.HasColumnType("float");
|
||||||
|
|
||||||
|
b.Property<int>("SurahId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TafsirId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<Guid?>("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<int>("Id")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("NameAr")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(80)
|
||||||
|
.HasColumnType("nvarchar(80)");
|
||||||
|
|
||||||
|
b.Property<string>("NameFa")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(80)
|
||||||
|
.HasColumnType("nvarchar(80)");
|
||||||
|
|
||||||
|
b.Property<int>("Number")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Number")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Surahs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.SurahBookmark", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("SurahId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TafsirId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid?>("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<Guid>("Id")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("Author")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("nvarchar(120)");
|
||||||
|
|
||||||
|
b.Property<string>("CoverUrl")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublished")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Slug")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("nvarchar(120)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("AudioPath")
|
||||||
|
.HasMaxLength(400)
|
||||||
|
.HasColumnType("nvarchar(400)");
|
||||||
|
|
||||||
|
b.Property<int>("AyahNumber")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("MessagesHtml")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("NotesHtml")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("SurahId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TafsirId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("AudioUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("AyahNumber")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("DurationSeconds")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Summary")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("SurahId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TafsirId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+422
@@ -0,0 +1,422 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AyahBrowseAndGharaati : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<int>(
|
||||||
|
name: "AyahNumber",
|
||||||
|
table: "ListeningProgresses",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SurahId",
|
||||||
|
table: "ListeningProgresses",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AyahBookmarks",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
TafsirId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SurahId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
AyahNumber = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
TafsirId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SurahId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
AyahNumber = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
TafsirId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SurahId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Note = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(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<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
TafsirId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SurahId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
AyahNumber = table.Column<int>(type: "int", nullable: false),
|
||||||
|
NotesHtml = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
MessagesHtml = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
VideoEmbedUrl = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||||
|
AudioPath = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: true),
|
||||||
|
UpdatedAt = table.Column<DateTimeOffset>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
EpisodeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||||
|
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Note = table.Column<string>(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<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
EpisodeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
|
||||||
|
DeviceId = table.Column<string>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+211
-31
@@ -80,12 +80,15 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("Ayahs");
|
b.ToTable("Ayahs");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("TafsirQuran.Domain.Entities.Bookmark", b =>
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.AyahBookmark", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("AyahNumber")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("datetimeoffset");
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
@@ -94,35 +97,43 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
|||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
b.Property<Guid>("EpisodeId")
|
|
||||||
.HasColumnType("uniqueidentifier");
|
|
||||||
|
|
||||||
b.Property<string>("Note")
|
b.Property<string>("Note")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("nvarchar(500)");
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("SurahId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TafsirId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<Guid?>("UserId")
|
b.Property<Guid?>("UserId")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("EpisodeId");
|
b.HasIndex("SurahId");
|
||||||
|
|
||||||
|
b.HasIndex("TafsirId");
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
b.HasIndex("DeviceId", "UserId", "EpisodeId")
|
b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasFilter("[UserId] IS NOT NULL");
|
.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<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("AyahNumber")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("datetimeoffset");
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
@@ -131,7 +142,10 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
|||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
b.Property<Guid>("EpisodeId")
|
b.Property<int>("SurahId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TafsirId")
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<Guid?>("UserId")
|
b.Property<Guid?>("UserId")
|
||||||
@@ -139,15 +153,17 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("EpisodeId");
|
b.HasIndex("SurahId");
|
||||||
|
|
||||||
|
b.HasIndex("TafsirId");
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
b.HasIndex("DeviceId", "UserId", "EpisodeId")
|
b.HasIndex("DeviceId", "UserId", "TafsirId", "SurahId", "AyahNumber")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasFilter("[UserId] IS NOT NULL");
|
.HasFilter("[UserId] IS NOT NULL");
|
||||||
|
|
||||||
b.ToTable("Favorites");
|
b.ToTable("AyahFavorites");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b =>
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b =>
|
||||||
@@ -156,17 +172,23 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
|||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uniqueidentifier");
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("AyahNumber")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("DeviceId")
|
b.Property<string>("DeviceId")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("nvarchar(64)");
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
b.Property<Guid>("EpisodeId")
|
|
||||||
.HasColumnType("uniqueidentifier");
|
|
||||||
|
|
||||||
b.Property<double>("PositionSeconds")
|
b.Property<double>("PositionSeconds")
|
||||||
.HasColumnType("float");
|
.HasColumnType("float");
|
||||||
|
|
||||||
|
b.Property<int>("SurahId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TafsirId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("UpdatedAt")
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
.HasColumnType("datetimeoffset");
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
@@ -175,7 +197,9 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("EpisodeId");
|
b.HasIndex("SurahId");
|
||||||
|
|
||||||
|
b.HasIndex("TafsirId");
|
||||||
|
|
||||||
b.HasIndex("UserId");
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
@@ -210,6 +234,48 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("Surahs");
|
b.ToTable("Surahs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.SurahBookmark", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("SurahId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TafsirId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid?>("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 =>
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -244,6 +310,48 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("Tafsirs");
|
b.ToTable("Tafsirs");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirAyahContent", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("AudioPath")
|
||||||
|
.HasMaxLength(400)
|
||||||
|
.HasColumnType("nvarchar(400)");
|
||||||
|
|
||||||
|
b.Property<int>("AyahNumber")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("MessagesHtml")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("NotesHtml")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("SurahId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("TafsirId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("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 =>
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -297,45 +405,67 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("Surah");
|
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()
|
.WithMany()
|
||||||
.HasForeignKey("EpisodeId")
|
.HasForeignKey("SurahId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TafsirId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
|
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
|
||||||
.WithMany("Bookmarks")
|
.WithMany("AyahBookmarks")
|
||||||
.HasForeignKey("UserId");
|
.HasForeignKey("UserId");
|
||||||
|
|
||||||
b.Navigation("Episode");
|
b.Navigation("Surah");
|
||||||
|
|
||||||
|
b.Navigation("Tafsir");
|
||||||
|
|
||||||
b.Navigation("User");
|
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()
|
.WithMany()
|
||||||
.HasForeignKey("EpisodeId")
|
.HasForeignKey("SurahId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TafsirId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
|
b.HasOne("TafsirQuran.Domain.Entities.AppUser", "User")
|
||||||
.WithMany("Favorites")
|
.WithMany("AyahFavorites")
|
||||||
.HasForeignKey("UserId");
|
.HasForeignKey("UserId");
|
||||||
|
|
||||||
b.Navigation("Episode");
|
b.Navigation("Surah");
|
||||||
|
|
||||||
|
b.Navigation("Tafsir");
|
||||||
|
|
||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b =>
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.ListeningProgress", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("TafsirQuran.Domain.Entities.TafsirEpisode", "Episode")
|
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("EpisodeId")
|
.HasForeignKey("SurahId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("TafsirQuran.Domain.Entities.Tafsir", "Tafsir")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TafsirId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
@@ -343,11 +473,57 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
|||||||
.WithMany("ListeningProgresses")
|
.WithMany("ListeningProgresses")
|
||||||
.HasForeignKey("UserId");
|
.HasForeignKey("UserId");
|
||||||
|
|
||||||
b.Navigation("Episode");
|
b.Navigation("Surah");
|
||||||
|
|
||||||
|
b.Navigation("Tafsir");
|
||||||
|
|
||||||
b.Navigation("User");
|
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 =>
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.TafsirEpisode", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
|
b.HasOne("TafsirQuran.Domain.Entities.Surah", "Surah")
|
||||||
@@ -369,11 +545,13 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("TafsirQuran.Domain.Entities.AppUser", b =>
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.AppUser", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Bookmarks");
|
b.Navigation("AyahBookmarks");
|
||||||
|
|
||||||
b.Navigation("Favorites");
|
b.Navigation("AyahFavorites");
|
||||||
|
|
||||||
b.Navigation("ListeningProgresses");
|
b.Navigation("ListeningProgresses");
|
||||||
|
|
||||||
|
b.Navigation("SurahBookmarks");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b =>
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.Surah", b =>
|
||||||
@@ -385,6 +563,8 @@ namespace TafsirQuran.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b =>
|
modelBuilder.Entity("TafsirQuran.Domain.Entities.Tafsir", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("AyahContents");
|
||||||
|
|
||||||
b.Navigation("Episodes");
|
b.Navigation("Episodes");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
|
|||||||
@@ -2,6 +2,93 @@ namespace TafsirQuran.Shared.Dtos;
|
|||||||
|
|
||||||
public record TafsirDto(Guid Id, string Title, string Author, string Slug, string? CoverUrl);
|
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(
|
public record EpisodeListItemDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Title,
|
string Title,
|
||||||
@@ -24,19 +111,3 @@ public record EpisodeDetailDto(
|
|||||||
string AudioUrl,
|
string AudioUrl,
|
||||||
int DurationSeconds,
|
int DurationSeconds,
|
||||||
string? Summary);
|
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);
|
|
||||||
|
|||||||
@@ -20,16 +20,25 @@ public class TafsirApiClient(HttpClient http, IAuthTokenStore tokenStore)
|
|||||||
return await http.GetFromJsonAsync<List<TafsirDto>>("api/tafsirs", ct) ?? [];
|
return await http.GetFromJsonAsync<List<TafsirDto>>("api/tafsirs", ct) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<EpisodeListItemDto>> GetEpisodesAsync(string slug, CancellationToken ct = default)
|
public async Task<IReadOnlyList<SurahListItemDto>> GetSurahsAsync(string deviceId, Guid? tafsirId = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await EnsureAuthHeaderAsync();
|
await EnsureAuthHeaderAsync();
|
||||||
return await http.GetFromJsonAsync<List<EpisodeListItemDto>>($"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<List<SurahListItemDto>>(q, ct) ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<EpisodeDetailDto?> GetEpisodeAsync(Guid id, CancellationToken ct = default)
|
public async Task<IReadOnlyList<AyahListItemDto>> GetAyahsAsync(string slug, int surahNumber, string deviceId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await EnsureAuthHeaderAsync();
|
await EnsureAuthHeaderAsync();
|
||||||
return await http.GetFromJsonAsync<EpisodeDetailDto>($"api/episodes/{id}", ct);
|
var url = $"api/tafsirs/{slug}/surahs/{surahNumber}/ayahs?deviceId={Uri.EscapeDataString(deviceId)}";
|
||||||
|
return await http.GetFromJsonAsync<List<AyahListItemDto>>(url, ct) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AyahDetailDto?> GetAyahAsync(string slug, int surahNumber, int ayahNumber, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await EnsureAuthHeaderAsync();
|
||||||
|
return await http.GetFromJsonAsync<AyahDetailDto>($"api/tafsirs/{slug}/surahs/{surahNumber}/ayahs/{ayahNumber}", ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ProgressDto?> GetProgressAsync(string deviceId, CancellationToken ct = default)
|
public async Task<ProgressDto?> GetProgressAsync(string deviceId, CancellationToken ct = default)
|
||||||
@@ -49,43 +58,66 @@ public class TafsirApiClient(HttpClient http, IAuthTokenStore tokenStore)
|
|||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<FavoriteDto>> GetFavoritesAsync(string deviceId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<AyahFavoriteDto>> GetFavoritesAsync(string deviceId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await EnsureAuthHeaderAsync();
|
await EnsureAuthHeaderAsync();
|
||||||
return await http.GetFromJsonAsync<List<FavoriteDto>>($"api/favorites?deviceId={Uri.EscapeDataString(deviceId)}", ct) ?? [];
|
return await http.GetFromJsonAsync<List<AyahFavoriteDto>>($"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();
|
await EnsureAuthHeaderAsync();
|
||||||
var response = await http.PostAsJsonAsync("api/favorites", request, ct);
|
var response = await http.PostAsJsonAsync("api/favorites/ayahs", request, ct);
|
||||||
response.EnsureSuccessStatusCode();
|
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();
|
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();
|
response.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<BookmarkDto>> GetBookmarksAsync(string deviceId, CancellationToken ct = default)
|
public async Task<IReadOnlyList<SurahBookmarkDto>> GetSurahBookmarksAsync(string deviceId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await EnsureAuthHeaderAsync();
|
await EnsureAuthHeaderAsync();
|
||||||
return await http.GetFromJsonAsync<List<BookmarkDto>>($"api/bookmarks?deviceId={Uri.EscapeDataString(deviceId)}", ct) ?? [];
|
return await http.GetFromJsonAsync<List<SurahBookmarkDto>>($"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();
|
await EnsureAuthHeaderAsync();
|
||||||
var response = await http.PostAsJsonAsync("api/bookmarks", request, ct);
|
var response = await http.PostAsJsonAsync("api/bookmarks/surahs", request, ct);
|
||||||
response.EnsureSuccessStatusCode();
|
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();
|
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<IReadOnlyList<AyahBookmarkDto>> GetAyahBookmarksAsync(string deviceId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
await EnsureAuthHeaderAsync();
|
||||||
|
return await http.GetFromJsonAsync<List<AyahBookmarkDto>>($"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();
|
response.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,29 +3,46 @@
|
|||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
<div class="audio-player" @ref="root">
|
<div class="audio-player" @ref="root">
|
||||||
<div class="player-title">@Episode.Title</div>
|
<div class="player-title">@Title</div>
|
||||||
<div class="player-actions">
|
<div class="player-actions">
|
||||||
<button type="button" class="chip @(IsFavorite ? "on" : null)" @onclick="OnFavoriteToggle">علاقه</button>
|
@if (ShowFavorite)
|
||||||
<button type="button" class="chip @(IsBookmarked ? "on" : null)" @onclick="OnBookmarkToggle">نشان</button>
|
{
|
||||||
|
<button type="button" class="chip @(IsFavorite ? "on" : null)" @onclick="OnFavoriteToggle">علاقه</button>
|
||||||
|
}
|
||||||
|
@if (ShowBookmark)
|
||||||
|
{
|
||||||
|
<button type="button" class="chip @(IsBookmarked ? "on" : null)" @onclick="OnBookmarkToggle">نشان</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<audio @ref="audioEl" preload="metadata" src="@AudioSrc"></audio>
|
@if (!string.IsNullOrWhiteSpace(AudioUrl))
|
||||||
|
{
|
||||||
|
<audio @ref="audioEl" preload="metadata" src="@AudioSrc"></audio>
|
||||||
|
|
||||||
<div class="player-controls">
|
<div class="player-controls">
|
||||||
<button type="button" class="play-btn" @onclick="TogglePlay">
|
<button type="button" class="play-btn" @onclick="TogglePlay">
|
||||||
@(playing ? "❚❚" : "▶")
|
@(playing ? "❚❚" : "▶")
|
||||||
</button>
|
</button>
|
||||||
<input class="seek" type="range" min="0" max="@duration.ToString("0.###")" step="0.1"
|
<input class="seek" type="range" min="0" max="@duration.ToString("0.###")" step="0.1"
|
||||||
value="@current.ToString("0.###")" @oninput="OnSeek" />
|
value="@current.ToString("0.###")" @oninput="OnSeek" />
|
||||||
<div class="time">@Format(current) / @Format(duration)</div>
|
<div class="time">@Format(current) / @Format(duration)</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="muted">صوت این آیه در دسترس نیست</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@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 double InitialPosition { get; set; }
|
||||||
[Parameter] public bool IsFavorite { get; set; }
|
[Parameter] public bool IsFavorite { get; set; }
|
||||||
[Parameter] public bool IsBookmarked { 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 OnFavoriteToggle { get; set; }
|
||||||
[Parameter] public EventCallback OnBookmarkToggle { get; set; }
|
[Parameter] public EventCallback OnBookmarkToggle { get; set; }
|
||||||
[Parameter] public EventCallback<double> OnProgress { get; set; }
|
[Parameter] public EventCallback<double> OnProgress { get; set; }
|
||||||
@@ -36,21 +53,24 @@
|
|||||||
private bool playing;
|
private bool playing;
|
||||||
private double current;
|
private double current;
|
||||||
private double duration;
|
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)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(AudioUrl)) return;
|
||||||
|
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
self = DotNetObjectReference.Create(this);
|
self = DotNetObjectReference.Create(this);
|
||||||
await Js.InvokeVoidAsync("tqAudio.bind", audioEl, self);
|
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;
|
playing = false;
|
||||||
current = InitialPosition;
|
current = InitialPosition;
|
||||||
await Js.InvokeVoidAsync("tqAudio.load", audioEl, AudioSrc, InitialPosition);
|
await Js.InvokeVoidAsync("tqAudio.load", audioEl, AudioSrc, InitialPosition);
|
||||||
@@ -59,6 +79,7 @@
|
|||||||
|
|
||||||
private string ResolveAudioUrl(string url)
|
private string ResolveAudioUrl(string url)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(url)) return "";
|
||||||
if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||||
return url;
|
return url;
|
||||||
return new Uri(new Uri(ApiOptions.BaseAddress), url.TrimStart('/')).ToString();
|
return new Uri(new Uri(ApiOptions.BaseAddress), url.TrimStart('/')).ToString();
|
||||||
|
|||||||
@@ -3,39 +3,68 @@
|
|||||||
@inject IDeviceIdStore DeviceStore
|
@inject IDeviceIdStore DeviceStore
|
||||||
|
|
||||||
<section class="list-page">
|
<section class="list-page">
|
||||||
<h1>نشانشدهها</h1>
|
<header class="page-header">
|
||||||
|
<h1>نشانها</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
@if (loading)
|
@if (loading)
|
||||||
{
|
{
|
||||||
<p class="muted">در حال بارگذاری...</p>
|
<p class="muted">در حال بارگذاری...</p>
|
||||||
}
|
}
|
||||||
else if (items.Count == 0)
|
|
||||||
{
|
|
||||||
<p class="muted">هنوز نشانی ذخیره نشده است.</p>
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<ul class="simple-list">
|
<h2 class="section-title">سورهها</h2>
|
||||||
@foreach (var item in items)
|
@if (surahs.Count == 0)
|
||||||
{
|
{
|
||||||
<li>
|
<p class="muted">سوره نشانشدهای نیست</p>
|
||||||
<a href="/tafsir/ghatreh-chekani">@item.Episode.Title</a>
|
}
|
||||||
<span class="ep-meta">@item.Episode.SurahNameFa — آیه @item.Episode.AyahNumber</span>
|
else
|
||||||
</li>
|
{
|
||||||
}
|
<div class="simple-list">
|
||||||
</ul>
|
@foreach (var b in surahs)
|
||||||
|
{
|
||||||
|
<a class="list-row" href="@($"/tafsir/ghatreh-chekani/surah/{b.SurahNumber}")">
|
||||||
|
<strong>@b.SurahNameFa</strong>
|
||||||
|
<span class="muted">سوره @b.SurahNumber</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2 class="section-title">آیهها</h2>
|
||||||
|
@if (ayahs.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="muted">آیه نشانشدهای نیست</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="simple-list">
|
||||||
|
@foreach (var b in ayahs)
|
||||||
|
{
|
||||||
|
<a class="list-row" href="@($"/tafsir/{b.TafsirSlug}/surah/{b.SurahNumber}/ayah/{b.AyahNumber}")">
|
||||||
|
<strong>@b.SurahNameFa — آیه @b.AyahNumber</strong>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<BookmarkDto> items = [];
|
|
||||||
private bool loading = true;
|
private bool loading = true;
|
||||||
|
private List<SurahBookmarkDto> surahs = [];
|
||||||
|
private List<AyahBookmarkDto> ayahs = [];
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (!firstRender) return;
|
if (!firstRender) return;
|
||||||
var deviceId = await DeviceStore.GetOrCreateAsync();
|
var deviceId = await DeviceStore.GetOrCreateAsync();
|
||||||
try { items = (await Api.GetBookmarksAsync(deviceId)).ToList(); }
|
try
|
||||||
catch { items = []; }
|
{
|
||||||
|
surahs = (await Api.GetSurahBookmarksAsync(deviceId)).ToList();
|
||||||
|
ayahs = (await Api.GetAyahBookmarksAsync(deviceId)).ToList();
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|||||||
@@ -3,39 +3,41 @@
|
|||||||
@inject IDeviceIdStore DeviceStore
|
@inject IDeviceIdStore DeviceStore
|
||||||
|
|
||||||
<section class="list-page">
|
<section class="list-page">
|
||||||
<h1>علاقهمندیها</h1>
|
<header class="page-header">
|
||||||
|
<h1>علاقهمندیها</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
@if (loading)
|
@if (loading)
|
||||||
{
|
{
|
||||||
<p class="muted">در حال بارگذاری...</p>
|
<p class="muted">در حال بارگذاری...</p>
|
||||||
}
|
}
|
||||||
else if (items.Count == 0)
|
else if (items.Count == 0)
|
||||||
{
|
{
|
||||||
<p class="muted">هنوز علاقهمندیای ندارید.</p>
|
<p class="muted">آیه علاقهمندی ثبت نشده</p>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<ul class="simple-list">
|
<div class="simple-list">
|
||||||
@foreach (var item in items)
|
@foreach (var f in items)
|
||||||
{
|
{
|
||||||
<li>
|
<a class="list-row" href="@($"/tafsir/{f.TafsirSlug}/surah/{f.SurahNumber}/ayah/{f.AyahNumber}")">
|
||||||
<a href="/tafsir/ghatreh-chekani">@item.Episode.Title</a>
|
<strong>@f.SurahNameFa — آیه @f.AyahNumber</strong>
|
||||||
<span class="ep-meta">@item.Episode.SurahNameFa — آیه @item.Episode.AyahNumber</span>
|
</a>
|
||||||
</li>
|
|
||||||
}
|
}
|
||||||
</ul>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<FavoriteDto> items = [];
|
|
||||||
private bool loading = true;
|
private bool loading = true;
|
||||||
|
private List<AyahFavoriteDto> items = [];
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (!firstRender) return;
|
if (!firstRender) return;
|
||||||
var deviceId = await DeviceStore.GetOrCreateAsync();
|
var deviceId = await DeviceStore.GetOrCreateAsync();
|
||||||
try { items = (await Api.GetFavoritesAsync(deviceId)).ToList(); }
|
try { items = (await Api.GetFavoritesAsync(deviceId)).ToList(); }
|
||||||
catch { items = []; }
|
catch { /* ignore */ }
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|||||||
@@ -1,71 +1,136 @@
|
|||||||
@page "/tafsir/{Slug}"
|
@page "/tafsir/{Slug}"
|
||||||
|
@page "/tafsir/{Slug}/surah/{SurahNumber:int}"
|
||||||
|
@page "/tafsir/{Slug}/surah/{SurahNumber:int}/ayah/{AyahNumber:int}"
|
||||||
@inject TafsirApiClient Api
|
@inject TafsirApiClient Api
|
||||||
@inject IDeviceIdStore DeviceStore
|
@inject IDeviceIdStore DeviceStore
|
||||||
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
<section class="tafsir-page">
|
<section class="tafsir-page">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>@title</h1>
|
<h1>@title</h1>
|
||||||
<p class="muted">@author</p>
|
<p class="muted">@author</p>
|
||||||
|
@if (view == View.Ayahs || view == View.Detail)
|
||||||
|
{
|
||||||
|
<button type="button" class="linkish" @onclick="BackToSurahs">← فهرست سورهها</button>
|
||||||
|
}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@if (loading)
|
@if (loading)
|
||||||
{
|
{
|
||||||
<p class="muted">در حال بارگذاری...</p>
|
<p class="muted">در حال بارگذاری...</p>
|
||||||
}
|
}
|
||||||
else
|
else if (view == View.Surahs)
|
||||||
{
|
{
|
||||||
<div class="episode-layout">
|
<div class="surah-grid">
|
||||||
<div class="episode-list">
|
@foreach (var s in surahs)
|
||||||
@foreach (var ep in episodes)
|
{
|
||||||
{
|
<div class="surah-card">
|
||||||
<button type="button"
|
<button type="button" class="surah-main" @onclick="() => OpenSurah(s.Number)">
|
||||||
class="episode-row @(selected?.Id == ep.Id ? "active" : null)"
|
<span class="surah-num">@s.Number</span>
|
||||||
@onclick="() => SelectAsync(ep.Id)">
|
<span class="surah-names">
|
||||||
<span class="ep-title">@ep.Title</span>
|
<strong>@s.NameFa</strong>
|
||||||
<span class="ep-meta">@ep.SurahNameFa — آیه @ep.AyahNumber</span>
|
<span class="muted">@s.NameAr</span>
|
||||||
|
</span>
|
||||||
|
<span class="muted">@s.AyahCount آیه</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="chip @(s.IsBookmarked ? "on" : null)"
|
||||||
|
@onclick="() => ToggleSurahBookmark(s)">نشان</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (view == View.Ayahs)
|
||||||
|
{
|
||||||
|
<h2 class="section-title">سوره @selectedSurah?.NameFa</h2>
|
||||||
|
<div class="ayah-list">
|
||||||
|
@foreach (var a in ayahs)
|
||||||
|
{
|
||||||
|
<button type="button" class="ayah-row" @onclick="() => OpenAyah(a.AyahNumber)">
|
||||||
|
<span class="ayah-num">@a.AyahNumber</span>
|
||||||
|
<span class="ayah-preview">@(a.TextFaPreview ?? "آیه " + a.AyahNumber)</span>
|
||||||
|
<span class="ayah-flags">
|
||||||
|
@if (a.IsFavorite) { <span class="dot fav" title="علاقه"></span> }
|
||||||
|
@if (a.IsBookmarked) { <span class="dot mark" title="نشان"></span> }
|
||||||
|
@if (a.HasAudio) { <span class="dot audio" title="صوت"></span> }
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (detail is not null)
|
||||||
|
{
|
||||||
|
<div class="ayah-detail">
|
||||||
|
<div class="ayah-nav">
|
||||||
|
<button type="button" class="chip" disabled="@(detail.PrevAyah is null)"
|
||||||
|
@onclick="() => OpenAyah(detail.PrevAyah!.Value)">آیه قبل</button>
|
||||||
|
<button type="button" class="chip" @onclick="() => OpenSurah(detail.SurahNumber)">فهرست آیات</button>
|
||||||
|
<button type="button" class="chip" disabled="@(detail.NextAyah is null)"
|
||||||
|
@onclick="() => OpenAyah(detail.NextAyah!.Value)">آیه بعد</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ayah-block">
|
||||||
|
<p class="ayah-ar">@detail.TextAr</p>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(detail.TextFa))
|
||||||
|
{
|
||||||
|
<p class="ayah-fa">@detail.TextFa</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="player-panel">
|
<AudioPlayer @key="@($"{detail.SurahNumber}:{detail.AyahNumber}")"
|
||||||
@if (detail is null)
|
Title="@($"{detail.SurahNameFa} — آیه {detail.AyahNumber}")"
|
||||||
{
|
AudioUrl="@detail.AudioUrl"
|
||||||
<p class="muted">یک آیه را انتخاب کنید</p>
|
AudioKey="@($"{detail.SurahNumber}:{detail.AyahNumber}")"
|
||||||
}
|
InitialPosition="initialPosition"
|
||||||
else
|
IsFavorite="isFavorite"
|
||||||
{
|
IsBookmarked="isBookmarked"
|
||||||
<div class="ayah-block">
|
OnFavoriteToggle="ToggleFavorite"
|
||||||
<p class="ayah-ar">@detail.AyahTextAr</p>
|
OnBookmarkToggle="ToggleBookmark"
|
||||||
@if (!string.IsNullOrWhiteSpace(detail.AyahTextFa))
|
OnProgress="SaveProgress" />
|
||||||
{
|
|
||||||
<p class="ayah-fa">@detail.AyahTextFa</p>
|
@if (!string.IsNullOrWhiteSpace(detail.NotesHtml))
|
||||||
}
|
{
|
||||||
|
<details class="content-block" open>
|
||||||
|
<summary>نکتهها</summary>
|
||||||
|
<div class="html-body">@((MarkupString)detail.NotesHtml)</div>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(detail.MessagesHtml))
|
||||||
|
{
|
||||||
|
<details class="content-block" open>
|
||||||
|
<summary>پیامها</summary>
|
||||||
|
<div class="html-body">@((MarkupString)detail.MessagesHtml)</div>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(detail.VideoEmbedUrl))
|
||||||
|
{
|
||||||
|
<details class="content-block">
|
||||||
|
<summary>فیلم</summary>
|
||||||
|
<div class="video-wrap">
|
||||||
|
<iframe src="@detail.VideoEmbedUrl" allowfullscreen loading="lazy"></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
<AudioPlayer @key="detail.Id"
|
}
|
||||||
Episode="detail"
|
|
||||||
InitialPosition="initialPosition"
|
|
||||||
IsFavorite="isFavorite"
|
|
||||||
IsBookmarked="isBookmarked"
|
|
||||||
OnFavoriteToggle="ToggleFavorite"
|
|
||||||
OnBookmarkToggle="ToggleBookmark"
|
|
||||||
OnProgress="SaveProgress" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
private enum View { Surahs, Ayahs, Detail }
|
||||||
|
|
||||||
[Parameter] public string Slug { get; set; } = "";
|
[Parameter] public string Slug { get; set; } = "";
|
||||||
|
[Parameter] public int? SurahNumber { get; set; }
|
||||||
|
[Parameter] public int? AyahNumber { get; set; }
|
||||||
|
|
||||||
private string title = "تفسیر";
|
private string title = "تفسیر";
|
||||||
private string author = "";
|
private string author = "";
|
||||||
|
private Guid tafsirId;
|
||||||
private bool loading = true;
|
private bool loading = true;
|
||||||
private string? loadedForSlug;
|
private string? routeKey;
|
||||||
private List<EpisodeListItemDto> episodes = [];
|
private View view = View.Surahs;
|
||||||
private EpisodeListItemDto? selected;
|
private List<SurahListItemDto> surahs = [];
|
||||||
private EpisodeDetailDto? detail;
|
private List<AyahListItemDto> ayahs = [];
|
||||||
|
private SurahListItemDto? selectedSurah;
|
||||||
|
private AyahDetailDto? detail;
|
||||||
private double initialPosition;
|
private double initialPosition;
|
||||||
private bool isFavorite;
|
private bool isFavorite;
|
||||||
private bool isBookmarked;
|
private bool isBookmarked;
|
||||||
@@ -74,31 +139,52 @@
|
|||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (firstRender) ready = true;
|
if (!firstRender) return;
|
||||||
if (!ready || string.IsNullOrWhiteSpace(Slug) || loadedForSlug == Slug) return;
|
ready = true;
|
||||||
|
deviceId = await DeviceStore.GetOrCreateAsync();
|
||||||
|
await LoadRouteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
if (!ready) return;
|
||||||
|
await LoadRouteAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadRouteAsync()
|
||||||
|
{
|
||||||
|
var key = $"{Slug}|{SurahNumber}|{AyahNumber}";
|
||||||
|
if (routeKey == key) return;
|
||||||
|
routeKey = key;
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
loadedForSlug = Slug;
|
await InvokeAsync(StateHasChanged);
|
||||||
deviceId = await DeviceStore.GetOrCreateAsync();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(deviceId))
|
||||||
|
deviceId = await DeviceStore.GetOrCreateAsync();
|
||||||
|
|
||||||
var tafsirs = await Api.GetTafsirsAsync();
|
var tafsirs = await Api.GetTafsirsAsync();
|
||||||
var tafsir = tafsirs.FirstOrDefault(t => t.Slug == Slug);
|
var tafsir = tafsirs.FirstOrDefault(t => t.Slug == slug);
|
||||||
title = tafsir?.Title ?? "تفسیر";
|
title = tafsir?.Title ?? "تفسیر";
|
||||||
author = tafsir?.Author ?? "";
|
author = tafsir?.Author ?? "";
|
||||||
|
tafsirId = tafsir?.Id ?? Guid.Empty;
|
||||||
|
|
||||||
episodes = (await Api.GetEpisodesAsync(Slug)).ToList();
|
if (AyahNumber is int a && SurahNumber is int s)
|
||||||
|
|
||||||
var progress = await Api.GetProgressAsync(deviceId);
|
|
||||||
var startId = progress?.Episode?.Id;
|
|
||||||
if (startId is null || episodes.All(e => e.Id != startId))
|
|
||||||
startId = episodes.FirstOrDefault()?.Id;
|
|
||||||
|
|
||||||
if (startId is Guid id)
|
|
||||||
{
|
{
|
||||||
initialPosition = progress?.EpisodeId == id ? progress.PositionSeconds : 0;
|
view = View.Detail;
|
||||||
await SelectAsync(id, keepPosition: true);
|
await LoadDetail(s, a);
|
||||||
|
}
|
||||||
|
else if (SurahNumber is int sn)
|
||||||
|
{
|
||||||
|
view = View.Ayahs;
|
||||||
|
await LoadAyahs(sn);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
view = View.Surahs;
|
||||||
|
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -108,25 +194,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SelectAsync(Guid id, bool keepPosition = false)
|
private string slug => Slug;
|
||||||
|
|
||||||
|
private void BackToSurahs() => Nav.NavigateTo($"/tafsir/{Slug}");
|
||||||
|
|
||||||
|
private void OpenSurah(int n) => Nav.NavigateTo($"/tafsir/{Slug}/surah/{n}");
|
||||||
|
|
||||||
|
private void OpenAyah(int n)
|
||||||
{
|
{
|
||||||
selected = episodes.FirstOrDefault(e => e.Id == id);
|
var s = SurahNumber ?? detail?.SurahNumber ?? selectedSurah?.Number ?? 1;
|
||||||
detail = await Api.GetEpisodeAsync(id);
|
Nav.NavigateTo($"/tafsir/{Slug}/surah/{s}/ayah/{n}");
|
||||||
if (!keepPosition) initialPosition = 0;
|
}
|
||||||
|
|
||||||
|
private async Task LoadAyahs(int surahNumber)
|
||||||
|
{
|
||||||
|
surahs = surahs.Count > 0 ? surahs : (await Api.GetSurahsAsync(deviceId, tafsirId == Guid.Empty ? null : tafsirId)).ToList();
|
||||||
|
selectedSurah = surahs.FirstOrDefault(x => x.Number == surahNumber);
|
||||||
|
ayahs = (await Api.GetAyahsAsync(Slug, surahNumber, deviceId)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadDetail(int surahNumber, int ayahNumber)
|
||||||
|
{
|
||||||
|
detail = await Api.GetAyahAsync(Slug, surahNumber, ayahNumber);
|
||||||
|
if (detail is null) return;
|
||||||
|
|
||||||
|
var progress = await Api.GetProgressAsync(deviceId);
|
||||||
|
initialPosition = progress is not null
|
||||||
|
&& progress.SurahNumber == surahNumber
|
||||||
|
&& progress.AyahNumber == ayahNumber
|
||||||
|
? progress.PositionSeconds
|
||||||
|
: 0;
|
||||||
|
|
||||||
var favorites = await Api.GetFavoritesAsync(deviceId);
|
var favorites = await Api.GetFavoritesAsync(deviceId);
|
||||||
var bookmarks = await Api.GetBookmarksAsync(deviceId);
|
var bookmarks = await Api.GetAyahBookmarksAsync(deviceId);
|
||||||
isFavorite = favorites.Any(f => f.EpisodeId == id);
|
isFavorite = favorites.Any(f => f.TafsirId == detail.TafsirId && f.SurahNumber == surahNumber && f.AyahNumber == ayahNumber);
|
||||||
isBookmarked = bookmarks.Any(b => b.EpisodeId == id);
|
isBookmarked = bookmarks.Any(b => b.TafsirId == detail.TafsirId && b.SurahNumber == surahNumber && b.AyahNumber == ayahNumber);
|
||||||
|
tafsirId = detail.TafsirId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleSurahBookmark(SurahListItemDto s)
|
||||||
|
{
|
||||||
|
if (tafsirId == Guid.Empty) return;
|
||||||
|
if (s.IsBookmarked)
|
||||||
|
await Api.RemoveSurahBookmarkAsync(tafsirId, s.Number, deviceId);
|
||||||
|
else
|
||||||
|
await Api.AddSurahBookmarkAsync(new ToggleSurahBookmarkRequest(tafsirId, s.Number, deviceId));
|
||||||
|
surahs = (await Api.GetSurahsAsync(deviceId, tafsirId)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ToggleFavorite()
|
private async Task ToggleFavorite()
|
||||||
{
|
{
|
||||||
if (detail is null) return;
|
if (detail is null) return;
|
||||||
if (isFavorite)
|
if (isFavorite)
|
||||||
await Api.RemoveFavoriteAsync(detail.Id, deviceId);
|
await Api.RemoveFavoriteAsync(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId);
|
||||||
else
|
else
|
||||||
await Api.AddFavoriteAsync(new ToggleEpisodeRequest(detail.Id, deviceId));
|
await Api.AddFavoriteAsync(new ToggleAyahRequest(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId));
|
||||||
isFavorite = !isFavorite;
|
isFavorite = !isFavorite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,15 +256,16 @@
|
|||||||
{
|
{
|
||||||
if (detail is null) return;
|
if (detail is null) return;
|
||||||
if (isBookmarked)
|
if (isBookmarked)
|
||||||
await Api.RemoveBookmarkAsync(detail.Id, deviceId);
|
await Api.RemoveAyahBookmarkAsync(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId);
|
||||||
else
|
else
|
||||||
await Api.AddBookmarkAsync(new ToggleEpisodeRequest(detail.Id, deviceId));
|
await Api.AddAyahBookmarkAsync(new ToggleAyahRequest(detail.TafsirId, detail.SurahNumber, detail.AyahNumber, deviceId));
|
||||||
isBookmarked = !isBookmarked;
|
isBookmarked = !isBookmarked;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveProgress(double position)
|
private async Task SaveProgress(double position)
|
||||||
{
|
{
|
||||||
if (detail is null) return;
|
if (detail is null) return;
|
||||||
await Api.SaveProgressAsync(new UpsertProgressRequest(detail.Id, position, deviceId));
|
await Api.SaveProgressAsync(new UpsertProgressRequest(
|
||||||
|
detail.TafsirId, detail.SurahNumber, detail.AyahNumber, position, deviceId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,3 +226,49 @@ button, input { font: inherit; }
|
|||||||
.link-btn {
|
.link-btn {
|
||||||
margin-top: 0.85rem; border: 0; background: transparent; color: var(--brand-deep); cursor: pointer;
|
margin-top: 0.85rem; border: 0; background: transparent; color: var(--brand-deep); cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.surah-grid { display: grid; gap: 0.55rem; }
|
||||||
|
.surah-card {
|
||||||
|
display: grid; grid-template-columns: 1fr auto; gap: 0.5rem; align-items: center;
|
||||||
|
border: 1px solid var(--line); background: var(--surface); border-radius: 16px; padding: 0.35rem;
|
||||||
|
}
|
||||||
|
.surah-main {
|
||||||
|
border: 0; background: transparent; cursor: pointer; text-align: right;
|
||||||
|
display: grid; grid-template-columns: auto 1fr auto; gap: 0.75rem; align-items: center;
|
||||||
|
padding: 0.65rem 0.75rem; width: 100%;
|
||||||
|
}
|
||||||
|
.surah-num {
|
||||||
|
width: 2.2rem; height: 2.2rem; border-radius: 10px; display: grid; place-items: center;
|
||||||
|
background: rgba(31,107,74,0.12); color: var(--brand-deep); font-weight: 700; font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.surah-names { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||||
|
.ayah-list { display: flex; flex-direction: column; gap: 0.4rem; max-height: 70vh; overflow: auto; }
|
||||||
|
.ayah-row {
|
||||||
|
border: 1px solid var(--line); background: var(--surface); border-radius: 14px;
|
||||||
|
padding: 0.75rem 0.9rem; text-align: right; cursor: pointer;
|
||||||
|
display: grid; grid-template-columns: auto 1fr auto; gap: 0.7rem; align-items: center;
|
||||||
|
}
|
||||||
|
.ayah-row:hover { border-color: rgba(31,107,74,0.45); background: #fff; }
|
||||||
|
.ayah-num { font-weight: 700; color: var(--brand); }
|
||||||
|
.ayah-preview { color: var(--muted); font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ayah-flags { display: flex; gap: 0.25rem; }
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||||
|
.dot.fav { background: #c45c26; }
|
||||||
|
.dot.mark { background: var(--brand); }
|
||||||
|
.dot.audio { background: #3b6ea5; }
|
||||||
|
.section-title { margin: 1.25rem 0 0.6rem; font-size: 1.05rem; }
|
||||||
|
.linkish { border: 0; background: transparent; color: var(--brand-deep); cursor: pointer; padding: 0; }
|
||||||
|
.ayah-nav { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||||
|
.content-block {
|
||||||
|
margin-top: 1rem; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
.content-block summary { cursor: pointer; font-weight: 600; margin-bottom: 0.5rem; }
|
||||||
|
.html-body { line-height: 1.95; color: var(--ink); }
|
||||||
|
.html-body p { margin: 0 0 0.65rem; }
|
||||||
|
.video-wrap { position: relative; aspect-ratio: 16 / 9; max-width: 520px; }
|
||||||
|
.video-wrap iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; border-radius: 12px; }
|
||||||
|
.list-row {
|
||||||
|
display: flex; flex-direction: column; gap: 0.2rem;
|
||||||
|
padding: 0.9rem 1rem; border-radius: 14px; background: var(--surface); border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.chip:disabled { opacity: 0.45; cursor: default; }
|
||||||
|
|||||||
Reference in New Issue
Block a user