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:
|
* 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user