feat: admin API integration, LogoMark, settings page, i18n, RTL font, docs
- Wire admin API into homepage + templates page (ISR 60s, null fallback) - Add src/lib/admin-api.ts with safeFetch helper - Add adminProjectToTemplateItem + adminProjectToCatalogTemplate mappers - Add LogoMark SVG component, replace Sparkles icon in Navbar/Footer/Sidebar - Add public/favicon.svg (SVG brand mark) - Rewrite opengraph-image.tsx with FlatRender branding - Add RTL/Persian font cascade: unlayered [dir=rtl] block forces Vazirmatn - Dashboard Settings page: Profile, Security, Billing, Notifications sections - Add src/lib/supabase/client.ts browser client - Admin API: GET /me, PATCH /profile, POST /change-password endpoints - Admin API DTOs: AdminUserDto, UpdateProfileRequest, ChangePasswordRequest - Admin UI Settings page with TanStack Query + mutations - Add CLAUDE.md + README.md to both repos for new-machine onboarding - Update PROJECT_MEMORY.md with session log - Add appsettings.Development.json.example template
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Server-side fetch from the FlatRender Admin API.
|
||||
*
|
||||
* All functions return hardcoded fallback data when:
|
||||
* - ADMIN_API_URL is not set, or
|
||||
* - The admin service is unreachable.
|
||||
*
|
||||
* This means the Next.js app works standalone with no admin service running.
|
||||
*/
|
||||
|
||||
const BASE = process.env.ADMIN_API_URL?.replace(/\/$/, "");
|
||||
|
||||
export interface AdminCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
type: "video" | "image" | "both";
|
||||
sortOrder: number;
|
||||
projectCount: number;
|
||||
}
|
||||
|
||||
export interface AdminProject {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
type: "video" | "image";
|
||||
status: string;
|
||||
categoryId?: string;
|
||||
categoryName?: string;
|
||||
coverImageUrl?: string;
|
||||
previewVideoUrl?: string;
|
||||
tags: string[];
|
||||
metaJson?: string;
|
||||
sortOrder: number;
|
||||
mediaCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminProjectsResponse {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: AdminProject[];
|
||||
}
|
||||
|
||||
// ── Fetch helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function safeFetch<T>(url: string): Promise<T | null> {
|
||||
if (!BASE) return null;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
next: { revalidate: 60 }, // cache for 60 s (ISR)
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<T>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchCategories(
|
||||
type?: "video" | "image"
|
||||
): Promise<AdminCategory[]> {
|
||||
const qs = type ? `?type=${type}` : "";
|
||||
return (
|
||||
(await safeFetch<AdminCategory[]>(`${BASE}/api/public/categories${qs}`)) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchProjects(opts?: {
|
||||
type?: "video" | "image";
|
||||
categorySlug?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<AdminProjectsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.type) params.set("type", opts.type);
|
||||
if (opts?.categorySlug) params.set("categorySlug", opts.categorySlug);
|
||||
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));
|
||||
|
||||
const qs = params.size ? `?${params}` : "";
|
||||
return (
|
||||
(await safeFetch<AdminProjectsResponse>(
|
||||
`${BASE}/api/public/projects${qs}`
|
||||
)) ?? { total: 0, page: 1, pageSize: 20, items: [] }
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchProject(slug: string): Promise<AdminProject | null> {
|
||||
return safeFetch<AdminProject>(`${BASE}/api/public/projects/${slug}`);
|
||||
}
|
||||
|
||||
/** True when admin API is configured and reachable. */
|
||||
export async function isAdminApiAvailable(): Promise<boolean> {
|
||||
if (!BASE) return false;
|
||||
try {
|
||||
const res = await fetch(`${BASE}/api/public/categories`, {
|
||||
next: { revalidate: 30 },
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
}
|
||||
@@ -430,3 +430,80 @@ export function toProjectTemplate(
|
||||
category: "Video",
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin API → catalog helpers ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map an admin category name (or slug) to the closest hardcoded
|
||||
* VideoSidebarCategoryId. Falls back to "social" when nothing matches.
|
||||
*/
|
||||
export function adminCategoryNameToSidebarId(
|
||||
categoryName?: string
|
||||
): Exclude<VideoSidebarCategoryId, "all"> {
|
||||
if (!categoryName) return "social";
|
||||
const n = categoryName.toLowerCase();
|
||||
if (n.includes("animat")) return "animation";
|
||||
if (n.includes("intro") || n.includes("logo")) return "intros";
|
||||
if (n.includes("edit")) return "editing";
|
||||
if (n.includes("invit")) return "invitation";
|
||||
if (
|
||||
n.includes("holiday") ||
|
||||
n.includes("christmas") ||
|
||||
n.includes("new year")
|
||||
)
|
||||
return "holiday";
|
||||
if (n.includes("slide")) return "slideshow";
|
||||
if (
|
||||
n.includes("present") ||
|
||||
n.includes("pitch") ||
|
||||
n.includes("deck")
|
||||
)
|
||||
return "presentations";
|
||||
if (
|
||||
n.includes("social") ||
|
||||
n.includes("instagram") ||
|
||||
n.includes("tiktok") ||
|
||||
n.includes("reel")
|
||||
)
|
||||
return "social";
|
||||
if (n.includes("ad") || n.includes("promo") || n.includes("ads"))
|
||||
return "ads";
|
||||
if (n.includes("sale") || n.includes("real estate")) return "sales";
|
||||
if (n.includes("music") || n.includes("audio")) return "music";
|
||||
return "social";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw AdminProject (from admin-api.ts) to a VideoCatalogTemplate
|
||||
* so admin-managed templates can be shown on the templates page.
|
||||
*
|
||||
* Import type only — do not import from admin-api in this file at runtime.
|
||||
*/
|
||||
export interface AdminProjectLike {
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: "video" | "image";
|
||||
categoryName?: string;
|
||||
coverImageUrl?: string;
|
||||
previewVideoUrl?: string;
|
||||
}
|
||||
|
||||
export function adminProjectToCatalogTemplate(
|
||||
p: AdminProjectLike
|
||||
): VideoCatalogTemplate {
|
||||
return {
|
||||
id: p.slug,
|
||||
name: p.title,
|
||||
videoCategory: adminCategoryNameToSidebarId(p.categoryName),
|
||||
aspectRatio: "widescreen",
|
||||
durationType: "flexible",
|
||||
premium: false,
|
||||
sceneCount: 0,
|
||||
supports4k: false,
|
||||
colorChange: false,
|
||||
scriptToVideo: false,
|
||||
description: p.description,
|
||||
isNew: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user