CI / quality (push) Waiting to run
Phase 1 cleanup of the personal-site revamp. Backend/architecture untouched; changes are limited to removing unused complexity and restoring feedback. Removals - Toast system: delete react-hot-toast, Toaster, QueryToastBridge, lib/toast, the toggle/easter-egg calls, related i18n keys and the dependency. - Contact protection: remove Turnstile + per-IP rate limiting (lib/contact-guard, lib/contact-protection, admin screen, form widget, app-config wiring, nav entry, test). - Speculative specs: delete orders, products, downloads, project-inquiry. Inline feedback (replaces toast, no new deps) - Add lib/admin-feedback (withFlash/readFlash) and components/admin/admin-flash, rendered centrally by AdminDashboardShell. - Emit success/error messages for media, site-settings, portfolio, smtp, marquee and maintenance actions; pages read them via searchParams. - Contact form shows validation/delivery errors inline; success still redirects to /success. Docs - Fix stale paths in frontend-system-* (components/root -> components/admin, lib/root-navigation -> lib/admin-navigation, drop phantom src/) and remove contact-protection references from docs and CLAUDE.md. - Add docs/PHASE0_DIAGNOSIS.md (diagnosis report). Note: proxy.ts self-fetch kept intentionally; it also drives maintenance mode.
609 lines
19 KiB
TypeScript
609 lines
19 KiB
TypeScript
"use server";
|
|
|
|
import { MediaUsageType, Prisma } from "@prisma/client";
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { isRedirectError } from "next/dist/client/components/redirect-error";
|
|
import { ZodError } from "zod";
|
|
|
|
import { routing } from "@/i18n/routing";
|
|
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
|
import { withFlash } from "@/lib/admin-feedback";
|
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
|
import { deleteEntityMediaUsages, replaceEntityMediaUsages } from "@/lib/media";
|
|
import { resolveMediaSelection } from "@/lib/media-service";
|
|
import { getLocalizedPath } from "@/lib/locale";
|
|
import { removeManagedMediaFile } from "@/lib/media-storage";
|
|
import { mediaFieldInputSchema } from "@/lib/media-validation";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { isCheckedFormValue } from "@/lib/form-data";
|
|
import { getSiteSettings } from "@/lib/app-config";
|
|
import {
|
|
assetInputSchema,
|
|
categoryInputSchema,
|
|
projectInputSchema,
|
|
sectionInputSchema,
|
|
} from "@/lib/portfolio-validation";
|
|
|
|
async function ensureAdmin() {
|
|
if (!(await isAdminAuthenticated())) {
|
|
await clearAdminSessionCookie();
|
|
redirect(getAdminAppPath("/"));
|
|
}
|
|
}
|
|
|
|
function getRedirectPath(formData: FormData, fallbackPath: string) {
|
|
return String(formData.get("redirectPath") ?? fallbackPath);
|
|
}
|
|
|
|
|
|
function normalizeCheckboxValue(formData: FormData, key: string) {
|
|
return isCheckedFormValue(formData.get(key));
|
|
}
|
|
|
|
function parseJsonArray(rawValue: FormDataEntryValue | null, key: string) {
|
|
if (typeof rawValue !== "string" || rawValue.trim() === "") {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(rawValue);
|
|
|
|
if (!Array.isArray(parsed)) {
|
|
throw new Error(`${key} muss ein Array sein.`);
|
|
}
|
|
|
|
return parsed;
|
|
} catch {
|
|
throw new Error(`Ungueltige ${key} Nutzdaten.`);
|
|
}
|
|
}
|
|
|
|
function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
|
|
if (typeof rawValue !== "string" || rawValue.trim() === "") {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(rawValue);
|
|
|
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
|
|
throw new Error(`${key} muss ein Objekt sein.`);
|
|
}
|
|
|
|
return parsed;
|
|
} catch {
|
|
throw new Error(`Ungueltige ${key} Nutzdaten.`);
|
|
}
|
|
}
|
|
|
|
function parseZodError(error: ZodError) {
|
|
return error.issues[0]?.message ?? "Validierung fehlgeschlagen.";
|
|
}
|
|
|
|
async function revalidatePortfolioPages() {
|
|
revalidatePath(toInternalAdminPath("/"));
|
|
revalidatePath(toInternalAdminPath("/media"));
|
|
revalidatePath(toInternalAdminPath("/portfolio"));
|
|
revalidatePath(toInternalAdminPath("/portfolio/categories"));
|
|
revalidatePath(toInternalAdminPath("/portfolio/projects"));
|
|
revalidatePath("/portfolio");
|
|
const siteSettings = await getSiteSettings();
|
|
|
|
for (const locale of routing.locales) {
|
|
revalidatePath(getLocalizedPath(locale, "/portfolio", siteSettings.defaultLocale));
|
|
}
|
|
}
|
|
|
|
async function removeManagedPaths(paths: string[]) {
|
|
for (const filePath of Array.from(new Set(paths.filter(Boolean)))) {
|
|
await removeManagedMediaFile(filePath);
|
|
}
|
|
}
|
|
|
|
export async function upsertCategoryAction(formData: FormData) {
|
|
await ensureAdmin();
|
|
|
|
const redirectPath = getRedirectPath(formData, getAdminAppPath("/portfolio/categories"));
|
|
|
|
try {
|
|
const parsed = categoryInputSchema.parse({
|
|
id: String(formData.get("id") ?? "").trim() || undefined,
|
|
slug: String(formData.get("slug") ?? ""),
|
|
nameAr: String(formData.get("nameAr") ?? ""),
|
|
nameEn: String(formData.get("nameEn") ?? ""),
|
|
nameDe: String(formData.get("nameDe") ?? ""),
|
|
descriptionAr: String(formData.get("descriptionAr") ?? ""),
|
|
descriptionEn: String(formData.get("descriptionEn") ?? ""),
|
|
descriptionDe: String(formData.get("descriptionDe") ?? ""),
|
|
sortOrder: String(formData.get("sortOrder") ?? "0"),
|
|
isActive: normalizeCheckboxValue(formData, "isActive"),
|
|
});
|
|
|
|
if (parsed.id) {
|
|
await prisma.category.update({
|
|
where: {
|
|
id: parsed.id,
|
|
},
|
|
data: parsed,
|
|
});
|
|
} else {
|
|
await prisma.category.create({
|
|
data: parsed,
|
|
});
|
|
}
|
|
|
|
await revalidatePortfolioPages();
|
|
redirect(withFlash(redirectPath, { success: "Kategorie gespeichert." }));
|
|
} catch (error) {
|
|
if (isRedirectError(error)) {
|
|
throw error;
|
|
}
|
|
|
|
const message =
|
|
error instanceof ZodError
|
|
? parseZodError(error)
|
|
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
|
|
? "Kategorie Slug muss eindeutig sein."
|
|
: "Kategorie konnte nicht gespeichert werden.";
|
|
|
|
redirect(withFlash(redirectPath, { error: message }));
|
|
}
|
|
}
|
|
|
|
export async function deleteCategoryAction(formData: FormData) {
|
|
await ensureAdmin();
|
|
|
|
const redirectPath = getRedirectPath(formData, getAdminAppPath("/portfolio/categories"));
|
|
const id = String(formData.get("id") ?? "");
|
|
|
|
try {
|
|
const projectCount = await prisma.portfolioProject.count({
|
|
where: {
|
|
categoryId: id,
|
|
},
|
|
});
|
|
|
|
if (projectCount > 0) {
|
|
redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." }));
|
|
}
|
|
|
|
await prisma.category.delete({
|
|
where: {
|
|
id,
|
|
},
|
|
});
|
|
|
|
await revalidatePortfolioPages();
|
|
redirect(withFlash(redirectPath, { success: "Kategorie geloescht." }));
|
|
} catch (error) {
|
|
if (isRedirectError(error)) {
|
|
throw error;
|
|
}
|
|
|
|
redirect(withFlash(redirectPath, { error: "Kategorie konnte nicht geloescht werden." }));
|
|
}
|
|
}
|
|
|
|
export async function saveProjectAction(formData: FormData) {
|
|
await ensureAdmin();
|
|
|
|
const fallbackRedirect = String(formData.get("id") ?? "").trim()
|
|
? getAdminAppPath(`/portfolio/projects/${String(formData.get("id") ?? "").trim()}`)
|
|
: getAdminAppPath("/portfolio/projects/new");
|
|
const redirectPath = getRedirectPath(formData, fallbackRedirect);
|
|
const uploadedPaths: string[] = [];
|
|
const createdMediaAssetIds: string[] = [];
|
|
|
|
try {
|
|
const sections = parseJsonArray(formData.get("sections"), "sections").map((section, index) =>
|
|
sectionInputSchema.parse({
|
|
...section,
|
|
media: section.media ? mediaFieldInputSchema.parse(section.media) : undefined,
|
|
sortOrder: section.sortOrder ?? index,
|
|
}),
|
|
);
|
|
|
|
const assets = parseJsonArray(formData.get("assets"), "assets").map((asset, index) =>
|
|
assetInputSchema.parse({
|
|
...asset,
|
|
media: asset.media ? mediaFieldInputSchema.parse(asset.media) : undefined,
|
|
sortOrder: asset.sortOrder ?? index,
|
|
}),
|
|
);
|
|
|
|
const coverMedia = parseJsonObject(formData.get("coverMedia"), "coverMedia");
|
|
|
|
const parsed = projectInputSchema.parse({
|
|
id: String(formData.get("id") ?? "").trim() || undefined,
|
|
categoryId: String(formData.get("categoryId") ?? ""),
|
|
slug: String(formData.get("slug") ?? ""),
|
|
viewMode: String(formData.get("viewMode") ?? "GRID"),
|
|
titleAr: String(formData.get("titleAr") ?? ""),
|
|
titleEn: String(formData.get("titleEn") ?? ""),
|
|
titleDe: String(formData.get("titleDe") ?? ""),
|
|
summaryAr: String(formData.get("summaryAr") ?? ""),
|
|
summaryEn: String(formData.get("summaryEn") ?? ""),
|
|
summaryDe: String(formData.get("summaryDe") ?? ""),
|
|
clientName: String(formData.get("clientName") ?? ""),
|
|
projectYear: String(formData.get("projectYear") ?? ""),
|
|
serviceLabelAr: String(formData.get("serviceLabelAr") ?? ""),
|
|
serviceLabelEn: String(formData.get("serviceLabelEn") ?? ""),
|
|
serviceLabelDe: String(formData.get("serviceLabelDe") ?? ""),
|
|
previewUrl: String(formData.get("previewUrl") ?? ""),
|
|
currentCoverImagePath: String(formData.get("currentCoverImagePath") ?? ""),
|
|
coverMedia: coverMedia ? mediaFieldInputSchema.parse(coverMedia) : undefined,
|
|
sortOrder: String(formData.get("sortOrder") ?? "0"),
|
|
isFeatured: normalizeCheckboxValue(formData, "isFeatured"),
|
|
isPublished: normalizeCheckboxValue(formData, "isPublished"),
|
|
sections,
|
|
assets,
|
|
});
|
|
|
|
const existingProject = parsed.id
|
|
? await prisma.portfolioProject.findUnique({
|
|
where: {
|
|
id: parsed.id,
|
|
},
|
|
select: {
|
|
isPublished: true,
|
|
publishedAt: true,
|
|
},
|
|
})
|
|
: null;
|
|
const shouldPublishNow = parsed.isPublished && !existingProject?.publishedAt;
|
|
|
|
const coverSelection = await resolveMediaSelection({
|
|
media: parsed.coverMedia,
|
|
uploadFile: formData.get("coverFile"),
|
|
folder: "covers",
|
|
fallbackLabel: parsed.titleDe || parsed.titleEn || parsed.titleAr || parsed.slug,
|
|
required: false,
|
|
});
|
|
|
|
if (coverSelection.createdAssetId) {
|
|
createdMediaAssetIds.push(coverSelection.createdAssetId);
|
|
}
|
|
|
|
if (coverSelection.uploadedUrl) {
|
|
uploadedPaths.push(coverSelection.uploadedUrl);
|
|
}
|
|
|
|
const sectionRows: Array<{
|
|
type: (typeof parsed.sections)[number]["type"];
|
|
titleAr: string;
|
|
titleEn: string;
|
|
titleDe: string;
|
|
bodyAr: string;
|
|
bodyEn: string;
|
|
bodyDe: string;
|
|
imagePath: string | null;
|
|
imageAssetId: string | null;
|
|
linkUrl: string | null;
|
|
sortOrder: number;
|
|
}> = [];
|
|
|
|
for (let index = 0; index < parsed.sections.length; index += 1) {
|
|
const section = parsed.sections[index];
|
|
const sectionSelection = await resolveMediaSelection({
|
|
media: section.media,
|
|
uploadFile: formData.get(`section-image-upload-${index}`),
|
|
folder: "sections",
|
|
fallbackLabel: section.titleDe || section.titleEn || section.titleAr || `section-${index + 1}`,
|
|
required: false,
|
|
});
|
|
|
|
if (sectionSelection.createdAssetId) {
|
|
createdMediaAssetIds.push(sectionSelection.createdAssetId);
|
|
}
|
|
|
|
if (sectionSelection.uploadedUrl) {
|
|
uploadedPaths.push(sectionSelection.uploadedUrl);
|
|
}
|
|
|
|
sectionRows.push({
|
|
type: section.type,
|
|
titleAr: section.titleAr,
|
|
titleEn: section.titleEn,
|
|
titleDe: section.titleDe,
|
|
bodyAr: section.bodyAr,
|
|
bodyEn: section.bodyEn,
|
|
bodyDe: section.bodyDe,
|
|
imagePath: sectionSelection.url || null,
|
|
imageAssetId: sectionSelection.assetId,
|
|
linkUrl: section.linkUrl || null,
|
|
sortOrder: index,
|
|
});
|
|
}
|
|
|
|
const assetRows: Array<{
|
|
kind: (typeof parsed.assets)[number]["kind"];
|
|
filePath: string;
|
|
mediaAssetId: string | null;
|
|
altAr: string;
|
|
altEn: string;
|
|
altDe: string;
|
|
sortOrder: number;
|
|
}> = [];
|
|
|
|
for (let index = 0; index < parsed.assets.length; index += 1) {
|
|
const asset = parsed.assets[index];
|
|
const assetSelection = await resolveMediaSelection({
|
|
media: asset.media,
|
|
uploadFile: asset.fileFieldName ? formData.get(asset.fileFieldName) : null,
|
|
folder: "assets",
|
|
fallbackLabel: asset.altDe || asset.altEn || asset.altAr || `asset-${index + 1}`,
|
|
required: true,
|
|
});
|
|
|
|
if (!assetSelection.url) {
|
|
throw new Error("Jede Datei Zeile braucht eine vorhandene Datei oder einen neuen Upload.");
|
|
}
|
|
|
|
if (assetSelection.createdAssetId) {
|
|
createdMediaAssetIds.push(assetSelection.createdAssetId);
|
|
}
|
|
|
|
if (assetSelection.uploadedUrl) {
|
|
uploadedPaths.push(assetSelection.uploadedUrl);
|
|
}
|
|
|
|
assetRows.push({
|
|
kind: asset.kind,
|
|
filePath: assetSelection.url,
|
|
mediaAssetId: assetSelection.assetId,
|
|
altAr: asset.altAr,
|
|
altEn: asset.altEn,
|
|
altDe: asset.altDe,
|
|
sortOrder: index,
|
|
});
|
|
}
|
|
|
|
const projectResult = await prisma.$transaction(async (tx) => {
|
|
const currentProject = parsed.id
|
|
? await tx.portfolioProject.update({
|
|
where: {
|
|
id: parsed.id,
|
|
},
|
|
data: {
|
|
categoryId: parsed.categoryId,
|
|
slug: parsed.slug,
|
|
viewMode: parsed.viewMode,
|
|
titleAr: parsed.titleAr,
|
|
titleEn: parsed.titleEn,
|
|
titleDe: parsed.titleDe,
|
|
summaryAr: parsed.summaryAr,
|
|
summaryEn: parsed.summaryEn,
|
|
summaryDe: parsed.summaryDe,
|
|
clientName: parsed.clientName,
|
|
projectYear: parsed.projectYear,
|
|
serviceLabelAr: parsed.serviceLabelAr,
|
|
serviceLabelEn: parsed.serviceLabelEn,
|
|
serviceLabelDe: parsed.serviceLabelDe,
|
|
previewUrl: parsed.previewUrl || null,
|
|
coverImagePath: coverSelection.url || null,
|
|
isFeatured: parsed.isFeatured,
|
|
isPublished: parsed.isPublished,
|
|
publishedAt: parsed.isPublished
|
|
? shouldPublishNow
|
|
? new Date()
|
|
: existingProject?.publishedAt ?? new Date()
|
|
: null,
|
|
sortOrder: parsed.sortOrder,
|
|
},
|
|
})
|
|
: await tx.portfolioProject.create({
|
|
data: {
|
|
categoryId: parsed.categoryId,
|
|
slug: parsed.slug,
|
|
viewMode: parsed.viewMode,
|
|
titleAr: parsed.titleAr,
|
|
titleEn: parsed.titleEn,
|
|
titleDe: parsed.titleDe,
|
|
summaryAr: parsed.summaryAr,
|
|
summaryEn: parsed.summaryEn,
|
|
summaryDe: parsed.summaryDe,
|
|
clientName: parsed.clientName,
|
|
projectYear: parsed.projectYear,
|
|
serviceLabelAr: parsed.serviceLabelAr,
|
|
serviceLabelEn: parsed.serviceLabelEn,
|
|
serviceLabelDe: parsed.serviceLabelDe,
|
|
previewUrl: parsed.previewUrl || null,
|
|
coverImagePath: coverSelection.url || null,
|
|
isFeatured: parsed.isFeatured,
|
|
isPublished: parsed.isPublished,
|
|
publishedAt: parsed.isPublished ? new Date() : null,
|
|
sortOrder: parsed.sortOrder,
|
|
},
|
|
});
|
|
|
|
await tx.portfolioSection.deleteMany({
|
|
where: {
|
|
projectId: currentProject.id,
|
|
},
|
|
});
|
|
|
|
await tx.portfolioAsset.deleteMany({
|
|
where: {
|
|
projectId: currentProject.id,
|
|
},
|
|
});
|
|
|
|
const createdSections = [];
|
|
|
|
for (const section of sectionRows) {
|
|
const createdSection = await tx.portfolioSection.create({
|
|
data: {
|
|
projectId: currentProject.id,
|
|
type: section.type,
|
|
titleAr: section.titleAr,
|
|
titleEn: section.titleEn,
|
|
titleDe: section.titleDe,
|
|
bodyAr: section.bodyAr,
|
|
bodyEn: section.bodyEn,
|
|
bodyDe: section.bodyDe,
|
|
imagePath: section.imagePath || null,
|
|
linkUrl: section.linkUrl || null,
|
|
sortOrder: section.sortOrder,
|
|
},
|
|
});
|
|
|
|
createdSections.push(createdSection);
|
|
}
|
|
|
|
const createdAssets = [];
|
|
|
|
for (const asset of assetRows) {
|
|
const createdAsset = await tx.portfolioAsset.create({
|
|
data: {
|
|
projectId: currentProject.id,
|
|
kind: asset.kind,
|
|
filePath: asset.filePath,
|
|
altAr: asset.altAr,
|
|
altEn: asset.altEn,
|
|
altDe: asset.altDe,
|
|
sortOrder: asset.sortOrder,
|
|
},
|
|
});
|
|
|
|
createdAssets.push(createdAsset);
|
|
}
|
|
|
|
return {
|
|
project: currentProject,
|
|
createdSections,
|
|
createdAssets,
|
|
};
|
|
});
|
|
|
|
await replaceEntityMediaUsages({
|
|
entityType: "portfolio-project",
|
|
entityId: projectResult.project.id,
|
|
usages: [
|
|
...(coverSelection.assetId
|
|
? [
|
|
{
|
|
assetId: coverSelection.assetId,
|
|
usageType: MediaUsageType.PORTFOLIO_COVER,
|
|
fieldKey: "cover",
|
|
},
|
|
]
|
|
: []),
|
|
...projectResult.createdSections.flatMap((section, index) =>
|
|
sectionRows[index]?.imageAssetId
|
|
? [
|
|
{
|
|
assetId: sectionRows[index].imageAssetId as string,
|
|
usageType: MediaUsageType.PORTFOLIO_SECTION,
|
|
fieldKey: section.id,
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
...projectResult.createdAssets.flatMap((asset, index) =>
|
|
assetRows[index]?.mediaAssetId
|
|
? [
|
|
{
|
|
assetId: assetRows[index].mediaAssetId as string,
|
|
usageType: MediaUsageType.PORTFOLIO_ASSET,
|
|
fieldKey: asset.id,
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
],
|
|
});
|
|
|
|
await revalidatePortfolioPages();
|
|
revalidatePath(toInternalAdminPath(`/portfolio/projects/${projectResult.project.id}`));
|
|
revalidatePath(`/portfolio/${projectResult.project.slug}`);
|
|
const siteSettings = await getSiteSettings();
|
|
|
|
for (const locale of routing.locales) {
|
|
revalidatePath(getLocalizedPath(locale, `/portfolio/${projectResult.project.slug}`, siteSettings.defaultLocale));
|
|
}
|
|
|
|
redirect(
|
|
withFlash(getAdminAppPath(`/portfolio/projects/${projectResult.project.id}`), {
|
|
success: "Projekt gespeichert.",
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
if (isRedirectError(error)) {
|
|
throw error;
|
|
}
|
|
|
|
const message =
|
|
error instanceof ZodError
|
|
? parseZodError(error)
|
|
: error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002"
|
|
? "Projekt Slug muss eindeutig sein."
|
|
: error instanceof Error
|
|
? error.message
|
|
: "Projekt konnte nicht gespeichert werden.";
|
|
|
|
await removeManagedPaths(uploadedPaths);
|
|
if (createdMediaAssetIds.length > 0) {
|
|
await prisma.mediaUsage.deleteMany({
|
|
where: {
|
|
assetId: {
|
|
in: createdMediaAssetIds,
|
|
},
|
|
},
|
|
});
|
|
await prisma.mediaAsset.deleteMany({
|
|
where: {
|
|
id: {
|
|
in: createdMediaAssetIds,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
redirect(withFlash(redirectPath, { error: message }));
|
|
}
|
|
}
|
|
|
|
export async function deleteProjectAction(formData: FormData) {
|
|
await ensureAdmin();
|
|
|
|
const id = String(formData.get("id") ?? "");
|
|
|
|
try {
|
|
const project = await prisma.portfolioProject.findUnique({
|
|
where: {
|
|
id,
|
|
},
|
|
select: {
|
|
slug: true,
|
|
},
|
|
});
|
|
|
|
if (!project) {
|
|
redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt nicht gefunden." }));
|
|
}
|
|
|
|
await prisma.portfolioProject.delete({
|
|
where: {
|
|
id,
|
|
},
|
|
});
|
|
await deleteEntityMediaUsages("portfolio-project", id);
|
|
|
|
await revalidatePortfolioPages();
|
|
revalidatePath(`/portfolio/${project.slug}`);
|
|
const siteSettings = await getSiteSettings();
|
|
|
|
for (const locale of routing.locales) {
|
|
revalidatePath(getLocalizedPath(locale, `/portfolio/${project.slug}`, siteSettings.defaultLocale));
|
|
}
|
|
|
|
redirect(withFlash(getAdminAppPath("/portfolio"), { success: "Projekt geloescht." }));
|
|
} catch (error) {
|
|
if (isRedirectError(error)) {
|
|
throw error;
|
|
}
|
|
|
|
redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt konnte nicht geloescht werden." }));
|
|
}
|
|
}
|