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:
* - ADMIN_API_URL is not set, or
* - The admin service is unreachable.
* Replaces the legacy admin-api (:5000) integration: categories, templates
* ("containers") and projects now come from the V2 content service via the
* 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 {
id: string;
@@ -47,32 +50,126 @@ export interface AdminProjectsResponse {
items: AdminProject[];
}
// ── Fetch helpers ─────────────────────────────────────────────────────────────
// ── V2 content-service response shapes (snake_case JSON) ──────────────────────
async function safeFetch<T>(url: string): Promise<T | null> {
if (!BASE) return null;
interface V2Category {
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 {
const res = await fetch(url, {
next: { revalidate: 60 }, // cache for 60 s (ISR)
const res = await fetch(gatewayUrl(path), {
next: { revalidate: 60 }, // ISR: cache public content for 60 s
headers: { Accept: "application/json" },
});
if (!res.ok) return null;
return res.json() as Promise<T>;
return (await res.json()) as T;
} catch {
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 ────────────────────────────────────────────────────────────────
export async function fetchCategories(
type?: "video" | "image"
_type?: "video" | "image"
): Promise<AdminCategory[]> {
const qs = type ? `?type=${type}` : "";
return (
(await safeFetch<AdminCategory[]>(`${BASE}/api/public/categories${qs}`)) ??
[]
);
// V2 categories have no video/image type; `_type` is accepted for API
// compatibility but the content service returns a single tree.
const tree = await safeGet<V2Category[]>("/v1/categories");
return tree ? flattenCategories(tree) : [];
}
export async function fetchProjects(opts?: {
@@ -82,30 +179,39 @@ export async function fetchProjects(opts?: {
page?: number;
pageSize?: number;
}): 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();
if (opts?.type) params.set("type", opts.type);
if (opts?.categorySlug) params.set("categorySlug", opts.categorySlug);
params.set("isPublished", "true");
if (opts?.search) params.set("search", opts.search);
if (opts?.page) params.set("page", String(opts.page));
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}` : "";
return (
(await safeFetch<AdminProjectsResponse>(
`${BASE}/api/public/projects${qs}`
)) ?? { total: 0, page: 1, pageSize: 20, items: [] }
);
const qs = params.toString() ? `?${params}` : "";
const paged = await safeGet<V2PagedContainers>(`/v1/templates${qs}`);
if (!paged) return { 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> {
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> {
if (!BASE) return false;
try {
const res = await fetch(`${BASE}/api/public/categories`, {
const res = await fetch(gatewayUrl("/v1/categories"), {
next: { revalidate: 30 },
});
return res.ok;