feat: repoint admin-api content reads to V2 gateway

src/lib/admin-api.ts now reads categories/templates/projects from the
V2 content service via the gateway (/v1/*) instead of the legacy
admin-api (:5000). Maps V2 snake_case DTOs (CategoryResponse tree,
PagedResponse<ContainerSummaryResponse>) into the existing camelCase
AdminCategory/AdminProject shapes, so call sites are unchanged and the
hardcoded fallback still applies when the gateway is unreachable.

V2 containers are video templates (primary_mode is a render mode, not a
type), so all map to type "video". Query params use camelCase
(pageSize/isPublished) since .NET binds query strings by property name,
not the snake_case used in response bodies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-30 05:15:45 +03:30
parent 8b86f17645
commit fa013b2305
+136 -30
View File
@@ -1,14 +1,17 @@
/** /**
* Server-side fetch from the FlatRender Admin API. * Server-side content reads from the FlatRender V2 API gateway.
* *
* All functions return hardcoded fallback data when: * Replaces the legacy admin-api (:5000) integration: categories, templates
* - ADMIN_API_URL is not set, or * ("containers") and projects now come from the V2 content service via the
* - The admin service is unreachable. * gateway at `/v1/*`. All V2 payloads are snake_case — this module maps them
* into the camelCase `AdminCategory` / `AdminProject` shapes the UI already
* consumes, so call sites are unchanged.
* *
* This means the Next.js app works standalone with no admin service running. * Every function returns hardcoded fallback data (empty arrays / null) when the
* gateway is unset or unreachable, so the app still works standalone.
*/ */
const BASE = process.env.ADMIN_API_URL?.replace(/\/$/, ""); import { gatewayUrl } from "@/lib/api/gateway";
export interface AdminCategory { export interface AdminCategory {
id: string; id: string;
@@ -47,32 +50,126 @@ export interface AdminProjectsResponse {
items: AdminProject[]; items: AdminProject[];
} }
// ── Fetch helpers ───────────────────────────────────────────────────────────── // ── V2 content-service response shapes (snake_case JSON) ──────────────────────
async function safeFetch<T>(url: string): Promise<T | null> { interface V2Category {
if (!BASE) return null; id: string;
parent_id?: string | null;
name: string;
slug: string;
description?: string | null;
image_url?: string | null;
icon?: string | null;
is_active: boolean;
sort: number;
children: V2Category[];
}
interface V2ContainerSummary {
id: string;
slug: string;
name: string;
description?: string | null;
image?: string | null;
demo?: string | null;
mini_demo?: string | null;
is_published: boolean;
is_premium: boolean;
is_mockup: boolean;
primary_mode: string;
rate_avg?: number | null;
rate_count: number;
view_count: number;
use_count: number;
sort: number;
sort_date: string;
category_slugs: string[];
tags: string[];
}
interface V2PagedContainers {
items: V2ContainerSummary[];
meta: { page: number; page_size: number; total: number; total_pages: number };
}
// ── Fetch helper ──────────────────────────────────────────────────────────────
async function safeGet<T>(path: string): Promise<T | null> {
try { try {
const res = await fetch(url, { const res = await fetch(gatewayUrl(path), {
next: { revalidate: 60 }, // cache for 60 s (ISR) next: { revalidate: 60 }, // ISR: cache public content for 60 s
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
}); });
if (!res.ok) return null; if (!res.ok) return null;
return res.json() as Promise<T>; return (await res.json()) as T;
} catch { } catch {
return null; return null;
} }
} }
// ── Mappers (V2 → UI shapes) ──────────────────────────────────────────────────
/**
* V2 containers are video templates — `primary_mode` is a render mode
* (FIX / FLEXIBLE / MockUp / …), not a video/image type. There is no image
* container concept, so every container maps to `type: "video"`.
*/
function containerToAdminProject(c: V2ContainerSummary): AdminProject {
return {
id: c.id,
title: c.name,
slug: c.slug,
description: c.description ?? undefined,
type: "video",
status: c.is_published ? "published" : "draft",
categoryId: undefined,
categoryName: undefined,
coverImageUrl: c.image ?? undefined,
previewVideoUrl: c.demo ?? c.mini_demo ?? undefined,
tags: c.tags ?? [],
metaJson: undefined,
sortOrder: c.sort,
mediaCount: 0,
createdAt: c.sort_date ?? "",
updatedAt: c.sort_date ?? "",
};
}
function categoryToAdmin(c: V2Category): AdminCategory {
return {
id: c.id,
name: c.name,
slug: c.slug,
description: c.description ?? undefined,
iconUrl: c.image_url ?? c.icon ?? undefined,
type: "both",
sortOrder: c.sort,
projectCount: 0,
};
}
/** Flatten the V2 category tree depth-first into a flat list. */
function flattenCategories(nodes: V2Category[]): AdminCategory[] {
const out: AdminCategory[] = [];
const walk = (list: V2Category[]) => {
for (const n of list) {
out.push(categoryToAdmin(n));
if (n.children?.length) walk(n.children);
}
};
walk(nodes);
return out;
}
// ── Public API ──────────────────────────────────────────────────────────────── // ── Public API ────────────────────────────────────────────────────────────────
export async function fetchCategories( export async function fetchCategories(
type?: "video" | "image" _type?: "video" | "image"
): Promise<AdminCategory[]> { ): Promise<AdminCategory[]> {
const qs = type ? `?type=${type}` : ""; // V2 categories have no video/image type; `_type` is accepted for API
return ( // compatibility but the content service returns a single tree.
(await safeFetch<AdminCategory[]>(`${BASE}/api/public/categories${qs}`)) ?? const tree = await safeGet<V2Category[]>("/v1/categories");
[] return tree ? flattenCategories(tree) : [];
);
} }
export async function fetchProjects(opts?: { export async function fetchProjects(opts?: {
@@ -82,30 +179,39 @@ export async function fetchProjects(opts?: {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
}): Promise<AdminProjectsResponse> { }): Promise<AdminProjectsResponse> {
// V2 .NET query binding uses C# property names (camelCase), NOT the snake_case
// used in response bodies. Send camelCase keys here.
const params = new URLSearchParams(); const params = new URLSearchParams();
if (opts?.type) params.set("type", opts.type); params.set("isPublished", "true");
if (opts?.categorySlug) params.set("categorySlug", opts.categorySlug);
if (opts?.search) params.set("search", opts.search); if (opts?.search) params.set("search", opts.search);
if (opts?.page) params.set("page", String(opts.page)); if (opts?.page) params.set("page", String(opts.page));
if (opts?.pageSize) params.set("pageSize", String(opts.pageSize)); if (opts?.pageSize) params.set("pageSize", String(opts.pageSize));
// Note: V2 has no video/image container type and filters categories by GUID
// (not slug), so `opts.type` / `opts.categorySlug` are intentionally ignored.
const qs = params.size ? `?${params}` : ""; const qs = params.toString() ? `?${params}` : "";
return ( const paged = await safeGet<V2PagedContainers>(`/v1/templates${qs}`);
(await safeFetch<AdminProjectsResponse>( if (!paged) return { total: 0, page: 1, pageSize: 20, items: [] };
`${BASE}/api/public/projects${qs}`
)) ?? { total: 0, page: 1, pageSize: 20, items: [] } return {
); total: paged.meta.total,
page: paged.meta.page,
pageSize: paged.meta.page_size,
items: (paged.items ?? []).map(containerToAdminProject),
};
} }
export async function fetchProject(slug: string): Promise<AdminProject | null> { export async function fetchProject(slug: string): Promise<AdminProject | null> {
return safeFetch<AdminProject>(`${BASE}/api/public/projects/${slug}`); const c = await safeGet<V2ContainerSummary>(
`/v1/templates/${encodeURIComponent(slug)}`
);
return c ? containerToAdminProject(c) : null;
} }
/** True when admin API is configured and reachable. */ /** True when the gateway content endpoint is reachable. */
export async function isAdminApiAvailable(): Promise<boolean> { export async function isAdminApiAvailable(): Promise<boolean> {
if (!BASE) return false;
try { try {
const res = await fetch(`${BASE}/api/public/categories`, { const res = await fetch(gatewayUrl("/v1/categories"), {
next: { revalidate: 30 }, next: { revalidate: 30 },
}); });
return res.ok; return res.ok;