diff --git a/messages/en.json b/messages/en.json index ed4a774..ec86cb5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -331,7 +331,8 @@ "stats": "Dashboard", "music": "Music", "homeEvents": "Home Events", - "comments": "Comments" + "comments": "Comments", + "routes": "Internal Routes" }, "appAdminNodesPage": { "title": "Render Nodes", diff --git a/messages/fa.json b/messages/fa.json index 68f0b58..c157575 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -331,7 +331,8 @@ "stats": "داشبورد", "music": "موسیقی", "homeEvents": "رویدادهای صفحه اصلی", - "comments": "نظرات" + "comments": "نظرات", + "routes": "مسیرهای داخلی" }, "appAdminNodesPage": { "title": "نودهای رندر", diff --git a/services/content/FlatRender.ContentSvc/Application/Services/CmsService.cs b/services/content/FlatRender.ContentSvc/Application/Services/CmsService.cs index 49d93ce..6ac5ce5 100644 --- a/services/content/FlatRender.ContentSvc/Application/Services/CmsService.cs +++ b/services/content/FlatRender.ContentSvc/Application/Services/CmsService.cs @@ -223,6 +223,41 @@ public class CmsService(ContentDbContext db) e.StartsAt = Utc(r.StartsAt); e.EndsAt = Utc(r.EndsAt); } + // ── Internal routes ───────────────────────────────────────────────────────── + + public async Task> GetRoutesAsync(Guid? tenantId) + { + return await db.InternalRoutes + .Where(x => x.TenantId == null || x.TenantId == tenantId) + .OrderBy(x => x.Priority) + .Select(r => new InternalRouteResponse(r.Id, r.Name, r.Image, r.Slug, r.Priority, r.LastDate)) + .ToListAsync(); + } + + public async Task CreateRouteAsync(UpsertRouteRequest req) + { + var r = new InternalRoute { Name = req.Name, Image = req.Image, Slug = req.Slug, Priority = req.Priority, LastDate = Utc(req.LastDate) }; + db.InternalRoutes.Add(r); + await db.SaveChangesAsync(); + return new InternalRouteResponse(r.Id, r.Name, r.Image, r.Slug, r.Priority, r.LastDate); + } + + public async Task UpdateRouteAsync(Guid id, UpsertRouteRequest req) + { + var r = await db.InternalRoutes.FindAsync(id) ?? throw new KeyNotFoundException($"Route {id} not found"); + r.Name = req.Name; r.Image = req.Image; r.Slug = req.Slug; r.Priority = req.Priority; + r.LastDate = Utc(req.LastDate); r.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return new InternalRouteResponse(r.Id, r.Name, r.Image, r.Slug, r.Priority, r.LastDate); + } + + public async Task DeleteRouteAsync(Guid id) + { + var r = await db.InternalRoutes.FindAsync(id) ?? throw new KeyNotFoundException($"Route {id} not found"); + db.InternalRoutes.Remove(r); + await db.SaveChangesAsync(); + } + // ── Website Settings ────────────────────────────────────────────────────── public async Task> GetSettingsAsync(Guid? tenantId, bool includeSecret = false) diff --git a/services/content/FlatRender.ContentSvc/Controllers/CmsController.cs b/services/content/FlatRender.ContentSvc/Controllers/CmsController.cs index ac969a1..6d96583 100644 --- a/services/content/FlatRender.ContentSvc/Controllers/CmsController.cs +++ b/services/content/FlatRender.ContentSvc/Controllers/CmsController.cs @@ -181,3 +181,30 @@ public class FavoritesController(CmsService svc) : ControllerBase return NoContent(); } } + +[ApiController] +[Route("v1/routes")] +public class RoutesController(CmsService svc) : ControllerBase +{ + [HttpGet] + public async Task List([FromQuery] Guid? tenantId = null) => + Ok(await svc.GetRoutesAsync(tenantId)); + + [Authorize(Roles = "Admin")] + [HttpPost] + public async Task Create([FromBody] UpsertRouteRequest req) => + Ok(await svc.CreateRouteAsync(req)); + + [Authorize(Roles = "Admin")] + [HttpPut("{id:guid}")] + public async Task Update(Guid id, [FromBody] UpsertRouteRequest req) => + Ok(await svc.UpdateRouteAsync(id, req)); + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id) + { + await svc.DeleteRouteAsync(id); + return NoContent(); + } +} diff --git a/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs b/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs index 855172d..407406b 100644 --- a/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs +++ b/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs @@ -95,6 +95,14 @@ public record CreateMusicTrackRequest( int Sort ); +public record UpsertRouteRequest( + string Slug, + string? Name = null, + string? Image = null, + int Priority = 5, + DateTime? LastDate = null +); + public record UpsertHomeEventRequest( string? Title, string? Subtitle, diff --git a/services/content/FlatRender.ContentSvc/Models/Responses/Responses.cs b/services/content/FlatRender.ContentSvc/Models/Responses/Responses.cs index 6b77399..bb9ccde 100644 --- a/services/content/FlatRender.ContentSvc/Models/Responses/Responses.cs +++ b/services/content/FlatRender.ContentSvc/Models/Responses/Responses.cs @@ -415,6 +415,15 @@ public record SlideResponse( bool IsActive ); +public record InternalRouteResponse( + Guid Id, + string? Name, + string? Image, + string Slug, + int Priority, + DateTime? LastDate +); + public record HomePageEventResponse( Guid Id, string? Title, diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go index adaabdb..5950667 100644 --- a/services/gateway/cmd/server/main.go +++ b/services/gateway/cmd/server/main.go @@ -112,6 +112,7 @@ func main() { v1.Any("/blogs/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/slides/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/home-events/*path", apiRL, optionalAuth, content.Handler()) + v1.Any("/routes/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/settings/*path", apiRL, optionalAuth, content.Handler()) v1.Any("/comments/*path", apiRL, auth, content.Handler()) v1.Any("/favorites/*path", apiRL, auth, content.Handler()) diff --git a/services/identity/FlatRender.IdentitySvc/Controllers/UsersController.cs b/services/identity/FlatRender.IdentitySvc/Controllers/UsersController.cs index 4b81ed6..bbfde98 100644 --- a/services/identity/FlatRender.IdentitySvc/Controllers/UsersController.cs +++ b/services/identity/FlatRender.IdentitySvc/Controllers/UsersController.cs @@ -40,6 +40,7 @@ public class UsersController(IUserService userService) : ControllerBase => Ok(await userService.GetByIdAsync(userId)); [HttpGet] + [Authorize(Roles = "Admin")] [ProducesResponseType(typeof(PagedResponse), 200)] public async Task Search( [FromQuery] string? q, @@ -49,6 +50,7 @@ public class UsersController(IUserService userService) : ControllerBase => Ok(await userService.SearchAsync(q, tenantId, page, pageSize)); [HttpPost("{userId:guid}/ban")] + [Authorize(Roles = "Admin")] [ProducesResponseType(204)] public async Task Ban(Guid userId, [FromBody] BanUserRequest request) { diff --git a/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs b/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs index 0e71d8e..22eef4c 100644 --- a/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs +++ b/services/studio/FlatRender.StudioSvc/Controllers/StudioController.cs @@ -19,6 +19,12 @@ public class StudioController(StudioService svc) : ControllerBase public async Task List([FromQuery] SavedProjectListRequest req) => Ok(await svc.ListProjectsAsync(UserId, req)); + // Admin: view any user's saved projects ("user videos" viewer). + [HttpGet("by-user/{userId:guid}")] + [Authorize(Roles = "Admin")] + public async Task ListByUser(Guid userId, [FromQuery] SavedProjectListRequest req) => + Ok(await svc.ListProjectsAsync(userId, req)); + [HttpGet("{id:guid}")] public async Task Get(Guid id) => Ok(await svc.GetProjectAsync(id, UserId)); diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index e5ce5fc..0647182 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -26,6 +26,7 @@ export default async function AdminLayout({ { href: "/admin/blogs", label: t("blogs") }, { href: "/admin/slides", label: t("slides") }, { href: "/admin/home-events", label: t("homeEvents") }, + { href: "/admin/routes", label: t("routes") }, { href: "/admin/comments", label: t("comments") }, { href: "/admin/files", label: t("media") }, { href: "/admin/ai", label: t("aiContent") }, diff --git a/src/app/[locale]/admin/routes/page.tsx b/src/app/[locale]/admin/routes/page.tsx new file mode 100644 index 0000000..380ab35 --- /dev/null +++ b/src/app/[locale]/admin/routes/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { AdminResource } from "@/components/admin/AdminResource"; +import { routesConfig } from "@/components/admin/admin-resources"; + +export default function Page() { + return ; +} diff --git a/src/components/admin/UserActions.tsx b/src/components/admin/UserActions.tsx index c60ed6b..0ee9207 100644 --- a/src/components/admin/UserActions.tsx +++ b/src/components/admin/UserActions.tsx @@ -20,6 +20,32 @@ export function UserActions({ row }: { row: Record; reload?: () const [seconds, setSeconds] = useState(""); const [renders, setRenders] = useState(""); const [planDays, setPlanDays] = useState(""); const [tags, setTags] = useState(""); const [note, setNote] = useState(""); const [status, setStatus] = useState("new"); + // discount / affiliate + const [dcCode, setDcCode] = useState(""); const [dcKind, setDcKind] = useState("Percentage"); + const [dcValue, setDcValue] = useState(""); const [dcProfit, setDcProfit] = useState(""); const [dcDays, setDcDays] = useState("30"); + // videos + const [vids, setVids] = useState> | null>(null); + + const createDiscount = async () => { + setBusy(true); setMsg(null); + const expires = new Date(Date.now() + (Number(dcDays) || 30) * 864e5).toISOString(); + const res = await fetch(`/api/admin/resource/discounts`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: dcCode || `user-${id.slice(0, 8)}`, code: dcCode, kind: dcKind, value: Number(dcValue) || 0, + owner_user_id: id, owner_profit_percentage: Number(dcProfit) || 0, expires_at: expires, + }), + }); + const d = await res.json().catch(() => null); + setMsg(res.ok ? (Number(dcProfit) > 0 ? "کد افیلیت ساخته شد ✓" : "کد تخفیف ساخته شد ✓") : (d?.error?.message ?? d?.error ?? "خطا")); + setBusy(false); + }; + + const loadVideos = async () => { + const r = await fetch(`/api/admin/resource/saved-projects/by-user/${id}?pageSize=50`, { cache: "no-store" }) + .then((x) => x.json()).catch(() => null); + setVids(r?.data ?? r?.items ?? (Array.isArray(r) ? r : [])); + }; const call = async (path: string, body: object, ok: string) => { setBusy(true); setMsg(null); @@ -96,6 +122,41 @@ export function UserActions({ row }: { row: Record; reload?: () +
+ +
+ setDcCode(e.target.value)} /> + + setDcValue(e.target.value)} /> + setDcProfit(e.target.value)} /> + setDcDays(e.target.value)} /> +
+ +
+ +
+
+ + +
+ {vids != null && ( + vids.length === 0 ?

ویدیویی یافت نشد.

: ( +
    + {vids.map((v) => ( +
  • + {String(v.name ?? v.original_project_name ?? "—")} + {String(v.type ?? "")} · {String(v.resolution ?? "")} +
  • + ))} +
+ ) + )} +
+
diff --git a/src/components/admin/admin-resources.tsx b/src/components/admin/admin-resources.tsx index f069d90..99ace6c 100644 --- a/src/components/admin/admin-resources.tsx +++ b/src/components/admin/admin-resources.tsx @@ -235,6 +235,26 @@ export const commentsConfig: ResourceConfig = { }, }; +export const routesConfig: ResourceConfig = { + title: "Internal Routes", + description: "Curated internal routes / featured links (slug, image, priority).", + basePath: "routes", + canCreate: true, + canEdit: true, + canDelete: true, + columns: [ + { key: "name", label: "Name" }, + { key: "slug", label: "Slug" }, + { key: "priority", label: "Priority" }, + ], + fields: [ + { key: "slug", label: "Slug (path)", required: true }, + { key: "name", label: "Name" }, + { key: "image", label: "Image", type: "image" }, + { key: "priority", label: "Priority", type: "number" }, + ], +}; + export const usersConfig: ResourceConfig = { title: "Users", description: "Accounts in this tenant. Ban or unban below.",