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:
+136
-30
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user