Implement portfolio admin management

This commit is contained in:
MOH
2026-03-07 16:06:20 +01:00
parent f983ce2203
commit ea64373853
35 changed files with 5126 additions and 178 deletions
+18
View File
@@ -0,0 +1,18 @@
export function moveArrayItem<T>(items: T[], fromIndex: number, toIndex: number) {
if (
fromIndex < 0 ||
toIndex < 0 ||
fromIndex >= items.length ||
toIndex >= items.length ||
fromIndex === toIndex
) {
return items;
}
const nextItems = [...items];
const [movedItem] = nextItems.splice(fromIndex, 1);
nextItems.splice(toIndex, 0, movedItem);
return nextItems;
}
+16
View File
@@ -0,0 +1,16 @@
export {
MEDIA_UPLOAD_ROOT as PORTFOLIO_UPLOAD_ROOT,
MAX_MEDIA_FILE_SIZE as MAX_FILE_SIZE,
isManagedMediaFilePath as isManagedPortfolioFilePath,
removeManagedMediaFile as removeManagedPortfolioFile,
resolveMediaUploadPath as resolvePortfolioUploadPath,
sanitizeBaseName,
} from "@/lib/media-storage";
import { saveMediaUpload } from "@/lib/media-storage";
export async function savePortfolioUpload(file: File, folder: string) {
const savedFile = await saveMediaUpload(file, folder);
return savedFile?.url ?? null;
}
+85
View File
@@ -0,0 +1,85 @@
import { PortfolioAssetKind, PortfolioSectionType } from "@prisma/client";
import { z } from "zod";
import { mediaFieldInputSchema } from "./media-validation";
const requiredText = (label: string) =>
z
.string()
.trim()
.min(1, `${label} is required.`);
const optionalTrimmedText = z.string().trim().optional().transform((value) => value ?? "");
export const categoryInputSchema = z.object({
id: z.string().trim().optional(),
slug: requiredText("Category slug")
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Category slug must be lowercase and hyphenated."),
nameAr: requiredText("Category nameAr"),
nameEn: requiredText("Category nameEn"),
nameDe: requiredText("Category nameDe"),
descriptionAr: requiredText("Category descriptionAr"),
descriptionEn: requiredText("Category descriptionEn"),
descriptionDe: requiredText("Category descriptionDe"),
sortOrder: z.coerce.number().int().min(0).max(9999),
isActive: z.boolean(),
});
export const sectionInputSchema = z.object({
id: z.string().trim().optional(),
type: z.nativeEnum(PortfolioSectionType),
titleAr: requiredText("Section titleAr"),
titleEn: requiredText("Section titleEn"),
titleDe: requiredText("Section titleDe"),
bodyAr: requiredText("Section bodyAr"),
bodyEn: requiredText("Section bodyEn"),
bodyDe: requiredText("Section bodyDe"),
imagePath: optionalTrimmedText,
media: mediaFieldInputSchema.optional(),
linkUrl: optionalTrimmedText.refine(
(value) => value === "" || /^https?:\/\//.test(value) || value.startsWith("/"),
"Section linkUrl must be an absolute URL or start with /.",
),
sortOrder: z.coerce.number().int().min(0).max(9999),
});
export const assetInputSchema = z.object({
id: z.string().trim().optional(),
kind: z.nativeEnum(PortfolioAssetKind),
filePath: optionalTrimmedText,
fileFieldName: optionalTrimmedText,
media: mediaFieldInputSchema.optional(),
altAr: requiredText("Asset altAr"),
altEn: requiredText("Asset altEn"),
altDe: requiredText("Asset altDe"),
sortOrder: z.coerce.number().int().min(0).max(9999),
});
export const projectInputSchema = z.object({
id: z.string().trim().optional(),
categoryId: requiredText("Project categoryId"),
slug: requiredText("Project slug")
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Project slug must be lowercase and hyphenated."),
titleAr: requiredText("Project titleAr"),
titleEn: requiredText("Project titleEn"),
titleDe: requiredText("Project titleDe"),
summaryAr: requiredText("Project summaryAr"),
summaryEn: requiredText("Project summaryEn"),
summaryDe: requiredText("Project summaryDe"),
clientName: requiredText("Project clientName"),
projectYear: z.coerce.number().int().min(2000).max(2100),
serviceLabelAr: requiredText("Project serviceLabelAr"),
serviceLabelEn: requiredText("Project serviceLabelEn"),
serviceLabelDe: requiredText("Project serviceLabelDe"),
previewUrl: optionalTrimmedText.refine(
(value) => value === "" || /^https?:\/\//.test(value),
"Project previewUrl must be an absolute URL.",
),
currentCoverImagePath: optionalTrimmedText,
coverMedia: mediaFieldInputSchema.optional(),
sortOrder: z.coerce.number().int().min(0).max(9999),
isFeatured: z.boolean(),
isPublished: z.boolean(),
sections: z.array(sectionInputSchema),
assets: z.array(assetInputSchema),
});
+344
View File
@@ -0,0 +1,344 @@
import type { Category, PortfolioAsset, PortfolioProject, PortfolioSection } from "@prisma/client";
import { getPortfolioMediaBindings } from "@/lib/media";
import type { AppLocale } from "@/lib/locale";
import { prisma } from "@/lib/prisma";
type CategoryRecord = Pick<
Category,
| "id"
| "slug"
| "nameAr"
| "nameEn"
| "nameDe"
| "descriptionAr"
| "descriptionEn"
| "descriptionDe"
| "sortOrder"
| "isActive"
>;
type SectionRecord = Pick<
PortfolioSection,
| "id"
| "type"
| "titleAr"
| "titleEn"
| "titleDe"
| "bodyAr"
| "bodyEn"
| "bodyDe"
| "imagePath"
| "linkUrl"
| "sortOrder"
>;
type AssetRecord = Pick<
PortfolioAsset,
"id" | "kind" | "filePath" | "altAr" | "altEn" | "altDe" | "sortOrder"
>;
type ProjectRecord = Pick<
PortfolioProject,
| "id"
| "slug"
| "titleAr"
| "titleEn"
| "titleDe"
| "summaryAr"
| "summaryEn"
| "summaryDe"
| "clientName"
| "projectYear"
| "serviceLabelAr"
| "serviceLabelEn"
| "serviceLabelDe"
| "previewUrl"
| "coverImagePath"
| "isFeatured"
| "isPublished"
| "publishedAt"
| "sortOrder"
>;
export type LocalizedContent = {
ar: string;
en: string;
de: string;
};
export type PortfolioCategoryView = {
id: string;
slug: string;
name: LocalizedContent;
description: LocalizedContent;
sortOrder: number;
isActive: boolean;
};
export type PortfolioSectionView = {
id: string;
type: SectionRecord["type"];
title: LocalizedContent;
body: LocalizedContent;
imagePath: string | null;
mediaAssetId: string | null;
linkUrl: string | null;
sortOrder: number;
};
export type PortfolioAssetView = {
id: string;
kind: AssetRecord["kind"];
filePath: string;
mediaAssetId: string | null;
alt: LocalizedContent;
sortOrder: number;
};
export type PortfolioProjectView = {
id: string;
slug: string;
title: LocalizedContent;
summary: LocalizedContent;
clientName: string;
projectYear: number;
serviceLabel: LocalizedContent;
previewUrl: string | null;
coverImagePath: string | null;
coverMediaAssetId: string | null;
isFeatured: boolean;
isPublished: boolean;
publishedAt: Date | null;
sortOrder: number;
category: PortfolioCategoryView;
sections: PortfolioSectionView[];
assets: PortfolioAssetView[];
};
function mapLocalizedContent(record: Record<string, unknown>, prefix: string): LocalizedContent {
return {
ar: String(record[`${prefix}Ar`] ?? ""),
en: String(record[`${prefix}En`] ?? ""),
de: String(record[`${prefix}De`] ?? ""),
};
}
function mapCategory(record: CategoryRecord): PortfolioCategoryView {
return {
id: record.id,
slug: record.slug,
name: mapLocalizedContent(record, "name"),
description: mapLocalizedContent(record, "description"),
sortOrder: record.sortOrder,
isActive: record.isActive,
};
}
function mapSection(record: SectionRecord, mediaAssetId: string | null): PortfolioSectionView {
return {
id: record.id,
type: record.type,
title: mapLocalizedContent(record, "title"),
body: mapLocalizedContent(record, "body"),
imagePath: record.imagePath,
mediaAssetId,
linkUrl: record.linkUrl,
sortOrder: record.sortOrder,
};
}
function mapAsset(record: AssetRecord, mediaAssetId: string | null): PortfolioAssetView {
return {
id: record.id,
kind: record.kind,
filePath: record.filePath,
mediaAssetId,
alt: mapLocalizedContent(record, "alt"),
sortOrder: record.sortOrder,
};
}
function mapProject(
record: ProjectRecord & {
category: CategoryRecord;
sections: SectionRecord[];
assets: AssetRecord[];
},
mediaBindings?: {
coverAssetId: string | null;
sectionAssetIds: Record<string, string>;
assetIds: Record<string, string>;
},
): PortfolioProjectView {
return {
id: record.id,
slug: record.slug,
title: mapLocalizedContent(record, "title"),
summary: mapLocalizedContent(record, "summary"),
clientName: record.clientName,
projectYear: record.projectYear,
serviceLabel: mapLocalizedContent(record, "serviceLabel"),
previewUrl: record.previewUrl,
coverImagePath: record.coverImagePath,
coverMediaAssetId: mediaBindings?.coverAssetId ?? null,
isFeatured: record.isFeatured,
isPublished: record.isPublished,
publishedAt: record.publishedAt,
sortOrder: record.sortOrder,
category: mapCategory(record.category),
sections: record.sections.map((section) =>
mapSection(section, mediaBindings?.sectionAssetIds[section.id] ?? null),
),
assets: record.assets.map((asset) => mapAsset(asset, mediaBindings?.assetIds[asset.id] ?? null)),
};
}
export function getLocalizedValue(
content: LocalizedContent,
locale: AppLocale,
fallbackLocale: AppLocale = "de",
): string {
const direct = content[locale]?.trim();
if (direct) {
return direct;
}
const fallback = content[fallbackLocale]?.trim();
if (fallback) {
return fallback;
}
return content.ar || content.en || content.de || "";
}
export async function getAdminPortfolioCategories() {
const categories = await prisma.category.findMany({
include: {
_count: {
select: {
projects: true,
},
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
});
return categories.map((category) => ({
...mapCategory(category),
projectCount: category._count.projects,
}));
}
export async function getActivePortfolioCategories() {
const categories = await prisma.category.findMany({
where: {
isActive: true,
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
});
return categories.map(mapCategory);
}
export async function getAdminPortfolioProjects(filters?: {
categoryId?: string;
status?: "all" | "draft" | "published";
}) {
const projects = await prisma.portfolioProject.findMany({
where: {
...(filters?.categoryId ? { categoryId: filters.categoryId } : {}),
...(filters?.status === "draft"
? { isPublished: false }
: filters?.status === "published"
? { isPublished: true }
: {}),
},
include: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
},
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
});
return projects.map(mapProject);
}
export async function getPublishedPortfolioProjects(filters?: { categorySlug?: string }) {
const projects = await prisma.portfolioProject.findMany({
where: {
isPublished: true,
category: {
isActive: true,
...(filters?.categorySlug ? { slug: filters.categorySlug } : {}),
},
},
include: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
},
orderBy: [{ sortOrder: "asc" }, { publishedAt: "desc" }, { createdAt: "desc" }],
});
return projects.map(mapProject);
}
export async function getPublishedPortfolioProjectBySlug(slug: string) {
const project = await prisma.portfolioProject.findFirst({
where: {
slug,
isPublished: true,
category: {
isActive: true,
},
},
include: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
},
});
return project ? mapProject(project) : null;
}
export async function getAdminPortfolioProjectById(id: string) {
const project = await prisma.portfolioProject.findUnique({
where: {
id,
},
include: {
category: true,
sections: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
assets: {
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
},
},
});
if (!project) {
return null;
}
const mediaBindings = await getPortfolioMediaBindings(project.id);
return mapProject(project, mediaBindings);
}
+56 -2
View File
@@ -1,9 +1,20 @@
import { LayoutDashboard, ShieldAlert, SwatchBook, type LucideIcon } from "lucide-react";
import {
FolderKanban,
ImageIcon,
LayoutDashboard,
PlusSquare,
ShieldAlert,
SwatchBook,
Tags,
type LucideIcon,
} from "lucide-react";
type RootNavigationCopy = {
overview: string;
maintenance: string;
uiKit: string;
portfolio: string;
media: string;
};
export type RootNavItem = {
@@ -11,11 +22,13 @@ export type RootNavItem = {
href: string;
icon: LucideIcon;
active?: boolean;
children?: RootNavItem[];
};
export function getRootNavigation(
copy: RootNavigationCopy,
active: "overview" | "maintenance" | "ui-kit",
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media",
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
): RootNavItem[] {
return [
{
@@ -36,5 +49,46 @@ export function getRootNavigation(
icon: SwatchBook,
active: active === "ui-kit",
},
{
label: copy.media,
href: "/root/media",
icon: ImageIcon,
active: active === "media",
},
{
label: copy.portfolio,
href: "/root/portfolio",
icon: FolderKanban,
active: active === "portfolio",
children:
active === "portfolio"
? [
{
label: "Overview",
href: "/root/portfolio",
icon: LayoutDashboard,
active: portfolioChild === "overview",
},
{
label: "Add Project",
href: "/root/portfolio/projects/new",
icon: PlusSquare,
active: portfolioChild === "new-project",
},
{
label: "Add Category",
href: "/root/portfolio/categories",
icon: Tags,
active: portfolioChild === "categories",
},
{
label: "Projects",
href: "/root/portfolio/projects",
icon: FolderKanban,
active: portfolioChild === "projects",
},
].filter((item, index, array) => array.findIndex((entry) => entry.href === item.href) === index)
: undefined,
},
];
}
-91
View File
@@ -2,14 +2,6 @@ import type { AppLocale } from "@/lib/locale";
type LocalizedText = Record<AppLocale, string>;
export type PortfolioItem = {
slug: string;
title: LocalizedText;
summary: LocalizedText;
category: LocalizedText;
year: string;
};
export type ProductItem = {
slug: string;
name: LocalizedText;
@@ -18,85 +10,6 @@ export type ProductItem = {
price: LocalizedText;
};
export const portfolioItems: PortfolioItem[] = [
{
slug: "brand-redesign",
title: {
de: "Brand Redesign",
en: "Brand Redesign",
ar: "إعادة تصميم الهوية",
},
summary: {
de: "Modernes Redesign fuer eine digitale Marke mit klarer Struktur.",
en: "Modern redesign for a digital brand with a clear system.",
ar: "إعادة تصميم حديثة لعلامة رقمية مع بنية واضحة.",
},
category: {
de: "Branding",
en: "Branding",
ar: "الهوية",
},
year: "2025",
},
{
slug: "commerce-relaunch",
title: {
de: "Commerce Relaunch",
en: "Commerce Relaunch",
ar: "إعادة إطلاق المتجر",
},
summary: {
de: "Relaunch eines Shops mit Fokus auf Performance und Conversion.",
en: "Store relaunch focused on performance and conversion.",
ar: "إعادة إطلاق متجر مع تركيز على الأداء والتحويل.",
},
category: {
de: "E-Commerce",
en: "E-Commerce",
ar: "التجارة الإلكترونية",
},
year: "2024",
},
{
slug: "saas-dashboard",
title: {
de: "SaaS Dashboard",
en: "SaaS Dashboard",
ar: "لوحة تحكم SaaS",
},
summary: {
de: "Admin Dashboard fuer Teams mit klaren KPIs und Reports.",
en: "Admin dashboard for teams with clear KPIs and reports.",
ar: "لوحة تحكم إدارية للفرق مع مؤشرات وتقارير واضحة.",
},
category: {
de: "Web App",
en: "Web App",
ar: "تطبيق ويب",
},
year: "2024",
},
{
slug: "campaign-site",
title: {
de: "Campaign Site",
en: "Campaign Site",
ar: "موقع حملة",
},
summary: {
de: "Landing Seite fuer Produktkampagnen mit schneller Iteration.",
en: "Landing experience for product campaigns and quick iteration.",
ar: "صفحة هبوط لحملات المنتجات مع تنفيذ سريع.",
},
category: {
de: "Marketing",
en: "Marketing",
ar: "التسويق",
},
year: "2023",
},
];
export const productItems: ProductItem[] = [
{
slug: "starter-kit",
@@ -173,10 +86,6 @@ export function pickText(text: LocalizedText, locale: AppLocale): string {
return text[locale];
}
export function getPortfolioItem(slug: string) {
return portfolioItems.find((item) => item.slug === slug);
}
export function getProductItem(slug: string) {
return productItems.find((item) => item.slug === slug);
}