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:
Soroush.Asadi
2026-05-27 09:06:51 +03:30
parent 4875e468fe
commit 36e264f3e3
27 changed files with 1275 additions and 88 deletions
+115
View File
@@ -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;
}
}
+8
View File
@@ -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!
);
}
+77
View File
@@ -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,
};
}