refactor: drop toast + over-engineered extras, add inline admin feedback
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.
This commit is contained in:
MohFarawati
2026-07-14 21:03:51 +02:00
parent 1a7b3397f6
commit d48497b992
62 changed files with 443 additions and 1453 deletions
+2 -2
View File
@@ -60,7 +60,7 @@ The full routing rewrite logic lives in `lib/admin-routing.ts` and `middleware.t
Prisma client is in `lib/prisma.ts`. All DB access must go through server-side modules in `lib/`. Client components must never access Prisma.
`AppConfig` is a key-value table used for all runtime configuration: site settings, SMTP, contact protection, marquee, maintenance mode, default locale. `lib/app-config.ts` is the aggregate entry point; individual settings are in `lib/site-settings.ts`, `lib/mail-settings.ts`, `lib/contact-protection.ts`, `lib/marquee-settings.ts`.
`AppConfig` is a key-value table used for all runtime configuration: site settings, SMTP, marquee, maintenance mode, default locale. `lib/app-config.ts` is the aggregate entry point; individual settings are in `lib/site-settings.ts`, `lib/mail-settings.ts`, `lib/marquee-settings.ts`.
### Module boundaries
@@ -82,7 +82,7 @@ Prisma client is in `lib/prisma.ts`. All DB access must go through server-side m
| AppConfig aggregate | `lib/app-config.ts` |
| Portfolio queries | `lib/portfolio.ts` |
| Media handling | `lib/media.ts` |
| Contact flow | `lib/contact-guard.ts`, `lib/mail.ts` |
| Contact flow | `lib/mail.ts` |
### Documentation to read by task scope
+13 -64
View File
@@ -4,8 +4,7 @@ import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error";
import { z } from "zod";
import { enforceContactRateLimit, verifyTurnstileToken } from "@/lib/contact-guard";
import { getContactProtectionSettings, getSiteSettings } from "@/lib/app-config";
import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
import { sendContactMessage } from "@/lib/mail";
@@ -16,55 +15,32 @@ const contactFormSchema = z.object({
phone: z.string().trim().max(40).optional(),
company: z.string().trim().max(120).optional(),
message: z.string().trim().min(10).max(5000),
turnstileToken: z.string().trim().optional(),
});
const contactErrorMessages = {
ar: {
invalid: "يرجى تعبئة كل الحقول بشكل صحيح.",
failed: "تعذر إرسال الرسالة حالياً.",
blocked: "تم إرسال عدد كبير من الطلبات. حاول لاحقاً.",
verification: "يرجى إكمال التحقق قبل الإرسال.",
},
en: {
invalid: "Please fill all fields correctly.",
failed: "Message could not be sent right now.",
blocked: "Too many requests. Please try again later.",
verification: "Please complete the verification before sending.",
},
de: {
invalid: "Bitte alle Felder korrekt ausfuellen.",
failed: "Nachricht konnte gerade nicht gesendet werden.",
blocked: "Zu viele Anfragen. Bitte spaeter erneut versuchen.",
verification: "Bitte die Verifizierung vor dem Senden abschliessen.",
},
ar: "تعذّر إرسال الرسالة. تأكد من تعبئة الحقول بشكل صحيح وحاول مجدداً.",
en: "Your message could not be sent. Please check the fields and try again.",
de: "Nachricht konnte nicht gesendet werden. Bitte Eingaben pruefen und erneut versuchen.",
} as const;
const contactSuccessMessages = {
ar: "شكراً على رسالتك.",
en: "Thank you for your message.",
de: "Vielen Dank fuer deine Nachricht.",
} as const;
function withMessage(pathname: string, type: "success" | "error", message: string) {
const params = new URLSearchParams();
params.set(type, message);
return `${pathname}?${params.toString()}`;
}
function getStringValue(formData: FormData, key: string) {
const value = formData.get(key);
return typeof value === "string" ? value : "";
}
function withContactError(pathname: string, message: string) {
const params = new URLSearchParams();
params.set("error", message);
return `${pathname}?${params.toString()}`;
}
export async function submitContactFormAction(formData: FormData) {
const siteSettings = await getSiteSettings();
const locale = resolveLocale(String(formData.get("locale") ?? ""), siteSettings.defaultLocale);
const contactPath = getLocalizedPath(locale, "/contact", siteSettings.defaultLocale);
try {
const protectionSettings = await getContactProtectionSettings();
const values = contactFormSchema.parse({
locale,
name: getStringValue(formData, "name"),
@@ -72,12 +48,8 @@ export async function submitContactFormAction(formData: FormData) {
phone: getStringValue(formData, "phone"),
company: getStringValue(formData, "company"),
message: getStringValue(formData, "message"),
turnstileToken: getStringValue(formData, "turnstileToken"),
});
await verifyTurnstileToken(protectionSettings, values.turnstileToken ?? "");
await enforceContactRateLimit(protectionSettings);
await sendContactMessage({
locale,
name: values.name,
@@ -87,36 +59,13 @@ export async function submitContactFormAction(formData: FormData) {
message: values.message,
});
redirect(
withMessage(
getLocalizedPath(locale, "/success", siteSettings.defaultLocale),
"success",
contactSuccessMessages[locale],
),
);
redirect(getLocalizedPath(locale, "/success", siteSettings.defaultLocale));
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
if (error instanceof z.ZodError) {
redirect(withMessage(contactPath, "error", contactErrorMessages[locale].invalid));
}
if (error instanceof Error && error.message === "Too many contact requests. Please try again later.") {
redirect(withMessage(contactPath, "error", contactErrorMessages[locale].blocked));
}
if (
error instanceof Error &&
(error.message === "Turnstile verification is required." ||
error.message === "Turnstile verification failed." ||
error.message === "Turnstile verification request failed.")
) {
redirect(withMessage(contactPath, "error", contactErrorMessages[locale].verification));
}
console.error("Contact form delivery failed.", error);
redirect(withMessage(contactPath, "error", contactErrorMessages[locale].failed));
redirect(withContactError(contactPath, contactErrorMessages[locale]));
}
}
+18 -7
View File
@@ -7,7 +7,7 @@ import { PageHero } from "@/components/layout/page-hero";
import { MotionFade } from "@/components/motion-fade";
import { ContactForm } from "@/components/site/contact-form";
import { buildLocalizedMetadata } from "@/lib/metadata";
import { getPublicContactProtectionSettings, getSiteSettings } from "@/lib/app-config";
import { getSiteSettings } from "@/lib/app-config";
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
import { AppCard } from "@/components/ui/app-card";
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -18,6 +18,9 @@ type ContactPageProps = {
params: Promise<{
locale: string;
}>;
searchParams?: Promise<{
error?: string;
}>;
};
export async function generateMetadata({ params }: ContactPageProps): Promise<Metadata> {
@@ -34,14 +37,12 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
});
}
export default async function ContactPage({ params }: ContactPageProps) {
export default async function ContactPage({ params, searchParams }: ContactPageProps) {
await params;
const siteSettings = await getSiteSettings();
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
const [t, protection] = await Promise.all([
getTranslations({ locale: localeKey, namespace: "contactPage" }),
getPublicContactProtectionSettings(),
]);
const t = await getTranslations({ locale: localeKey, namespace: "contactPage" });
const contactError = (await searchParams)?.error;
return (
<>
@@ -52,6 +53,17 @@ export default async function ContactPage({ params }: ContactPageProps) {
description={t("intro")}
/>
{contactError ? (
<Container className="pb-6">
<p
role="alert"
className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm font-medium text-destructive"
>
{contactError}
</p>
</Container>
) : null}
<Container className="grid gap-6 pb-12 lg:grid-cols-2 lg:pb-16">
<MotionFade>
<AppCard level={3}>
@@ -85,7 +97,6 @@ export default async function ContactPage({ params }: ContactPageProps) {
action={submitContactFormAction}
locale={localeKey}
previewHref={getLocalizedPath(localeKey, "/success", siteSettings.defaultLocale)}
protection={protection}
copy={{
name: t("name"),
email: t("email"),
+9 -1
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminAppPath } from "@/lib/admin-routing";
import { getMaintenanceMode } from "@/lib/app-config";
@@ -28,7 +29,13 @@ const copy = {
backToSite: "Zur Website",
};
export default async function AdminMaintenancePage() {
export default async function AdminMaintenancePage({
searchParams,
}: {
searchParams?: Promise<{ success?: string; error?: string }>;
}) {
const flash = readFlash(await searchParams);
const authenticated = await isAdminAuthenticated();
if (!authenticated) {
@@ -47,6 +54,7 @@ export default async function AdminMaintenancePage() {
<AdminDashboardShell
copy={copy}
active="maintenance"
flash={flash}
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
+3 -8
View File
@@ -6,6 +6,7 @@ import { isRedirectError } from "next/dist/client/components/redirect-error";
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { withFlash } from "@/lib/admin-feedback";
import { getSiteSettings, updateMarqueeSettings } from "@/lib/app-config";
import { routing } from "@/i18n/routing";
import { getLocalizedPath } from "@/lib/locale";
@@ -17,12 +18,6 @@ async function ensureAdmin() {
}
}
function withMessage(pathname: string, type: "success" | "error", message: string) {
const params = new URLSearchParams();
params.set(type, message);
return `${pathname}?${params.toString()}`;
}
async function revalidateMarqueePages() {
revalidatePath(toInternalAdminPath("/"));
@@ -73,13 +68,13 @@ export async function saveMarqueeSettingsAction(formData: FormData) {
await updateMarqueeSettings(settings);
await revalidateMarqueePages();
redirect(withMessage(getAdminAppPath("/marquee"), "success", "Marquee gespeichert."));
redirect(withFlash(getAdminAppPath("/marquee"), { success: "Marquee gespeichert." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
const message = error instanceof Error ? error.message : "Marquee konnte nicht gespeichert werden.";
redirect(withMessage(getAdminAppPath("/marquee"), "error", message));
redirect(withFlash(getAdminAppPath("/marquee"), { error: message }));
}
}
+9 -1
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { MarqueeSettingsForm } from "@/components/admin/marquee-settings-form";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { MotionFade } from "@/components/motion-fade";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminAppPath } from "@/lib/admin-routing";
@@ -26,7 +27,13 @@ const copy = {
backToSite: "Zur Website",
};
export default async function AdminMarqueePage() {
export default async function AdminMarqueePage({
searchParams,
}: {
searchParams?: Promise<{ success?: string; error?: string }>;
}) {
const flash = readFlash(await searchParams);
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
}
@@ -44,6 +51,7 @@ export default async function AdminMarqueePage() {
<AdminDashboardShell
copy={copy}
active="marquee"
flash={flash}
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
+7 -12
View File
@@ -7,6 +7,7 @@ import { isRedirectError } from "next/dist/client/components/redirect-error";
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { withFlash } from "@/lib/admin-feedback";
import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media";
import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service";
import { isManagedMediaFilePath } from "@/lib/media-storage";
@@ -19,12 +20,6 @@ async function ensureAdmin() {
}
}
function withMessage(pathname: string, type: "success" | "error", message: string) {
const params = new URLSearchParams();
params.set(type, message);
return `${pathname}?${params.toString()}`;
}
function revalidateMediaPages() {
revalidatePath(toInternalAdminPath("/"));
@@ -47,14 +42,14 @@ export async function createMediaAssetAction(formData: FormData) {
});
revalidateMediaPages();
redirect(withMessage(getAdminAppPath("/media"), "success", "Datei gespeichert."));
redirect(withFlash(getAdminAppPath("/media"), { success: "Datei gespeichert." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
const message = error instanceof Error ? error.message : "Datei konnte nicht gespeichert werden.";
redirect(withMessage(getAdminAppPath("/media"), "error", message));
redirect(withFlash(getAdminAppPath("/media"), { error: message }));
}
}
@@ -67,13 +62,13 @@ export async function deleteMediaAssetAction(formData: FormData) {
const asset = await getMediaAssetById(assetId);
if (!asset) {
redirect(withMessage(getAdminAppPath("/media"), "error", "Datei nicht gefunden."));
redirect(withFlash(getAdminAppPath("/media"), { error: "Datei nicht gefunden." }));
}
const usageCount = await countMediaUsageReferences(asset.id);
if (usageCount > 0) {
redirect(withMessage(getAdminAppPath("/media"), "error", "Datei wird noch verwendet."));
redirect(withFlash(getAdminAppPath("/media"), { error: "Datei wird noch verwendet." }));
}
await prisma.mediaAsset.delete({
@@ -90,13 +85,13 @@ export async function deleteMediaAssetAction(formData: FormData) {
}
revalidateMediaPages();
redirect(withMessage(getAdminAppPath("/media"), "success", "Datei geloescht."));
redirect(withFlash(getAdminAppPath("/media"), { success: "Datei geloescht." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
const message = error instanceof Error ? error.message : "Datei konnte nicht geloescht werden.";
redirect(withMessage(getAdminAppPath("/media"), "error", message));
redirect(withFlash(getAdminAppPath("/media"), { error: message }));
}
}
+9 -1
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { MediaLibraryManager } from "@/components/admin/media-library-manager";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminAppPath } from "@/lib/admin-routing";
import { getAdminMediaAssets } from "@/lib/media";
@@ -21,7 +22,13 @@ const copy = {
backToSite: "Zur Website",
};
export default async function AdminMediaPage() {
export default async function AdminMediaPage({
searchParams,
}: {
searchParams?: Promise<{ success?: string; error?: string }>;
}) {
const flash = readFlash(await searchParams);
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
}
@@ -39,6 +46,7 @@ export default async function AdminMediaPage() {
<AdminDashboardShell
copy={copy}
active="media"
flash={flash}
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
+4
View File
@@ -21,6 +21,7 @@ import {
setAdminSessionCookie,
} from "@/lib/admin-auth";
import { getAdminAppPath } from "@/lib/admin-routing";
import { readFlash } from "@/lib/admin-feedback";
import { getMaintenanceMode } from "@/lib/app-config";
import { getAdminMediaAssets } from "@/lib/media";
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
@@ -28,6 +29,7 @@ import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/po
type AdminPageProps = {
searchParams?: Promise<{
error?: string;
success?: string;
}>;
};
@@ -63,6 +65,7 @@ const copy = {
export default async function AdminPage({ searchParams }: AdminPageProps) {
const resolvedSearchParams = await searchParams;
const flash = readFlash(resolvedSearchParams);
const authConfigured = isAdminAuthConfigured();
const basicConfigured = Boolean(
process.env.ADMIN_BASIC_AUTH_USER && process.env.ADMIN_BASIC_AUTH_PASS,
@@ -186,6 +189,7 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
<AdminDashboardShell
copy={copy}
active="overview"
flash={flash}
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
+13 -16
View File
@@ -8,6 +8,7 @@ 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";
@@ -35,12 +36,6 @@ function getRedirectPath(formData: FormData, fallbackPath: string) {
return String(formData.get("redirectPath") ?? fallbackPath);
}
function withMessage(pathname: string, type: "success" | "error", message: string) {
const params = new URLSearchParams();
params.set(type, message);
return `${pathname}?${params.toString()}`;
}
function normalizeCheckboxValue(formData: FormData, key: string) {
return isCheckedFormValue(formData.get(key));
@@ -139,7 +134,7 @@ export async function upsertCategoryAction(formData: FormData) {
}
await revalidatePortfolioPages();
redirect(withMessage(redirectPath, "success", "Kategorie gespeichert."));
redirect(withFlash(redirectPath, { success: "Kategorie gespeichert." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
@@ -152,7 +147,7 @@ export async function upsertCategoryAction(formData: FormData) {
? "Kategorie Slug muss eindeutig sein."
: "Kategorie konnte nicht gespeichert werden.";
redirect(withMessage(redirectPath, "error", message));
redirect(withFlash(redirectPath, { error: message }));
}
}
@@ -170,7 +165,7 @@ export async function deleteCategoryAction(formData: FormData) {
});
if (projectCount > 0) {
redirect(withMessage(redirectPath, "error", "Kategorie mit Projekten kann nicht geloescht werden."));
redirect(withFlash(redirectPath, { error: "Kategorie mit Projekten kann nicht geloescht werden." }));
}
await prisma.category.delete({
@@ -180,13 +175,13 @@ export async function deleteCategoryAction(formData: FormData) {
});
await revalidatePortfolioPages();
redirect(withMessage(redirectPath, "success", "Kategorie geloescht."));
redirect(withFlash(redirectPath, { success: "Kategorie geloescht." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
redirect(withMessage(redirectPath, "error", "Kategorie konnte nicht geloescht werden."));
redirect(withFlash(redirectPath, { error: "Kategorie konnte nicht geloescht werden." }));
}
}
@@ -529,7 +524,9 @@ export async function saveProjectAction(formData: FormData) {
}
redirect(
withMessage(getAdminAppPath(`/portfolio/projects/${projectResult.project.id}`), "success", "Projekt gespeichert."),
withFlash(getAdminAppPath(`/portfolio/projects/${projectResult.project.id}`), {
success: "Projekt gespeichert.",
}),
);
} catch (error) {
if (isRedirectError(error)) {
@@ -562,7 +559,7 @@ export async function saveProjectAction(formData: FormData) {
},
});
}
redirect(withMessage(redirectPath, "error", message));
redirect(withFlash(redirectPath, { error: message }));
}
}
@@ -582,7 +579,7 @@ export async function deleteProjectAction(formData: FormData) {
});
if (!project) {
redirect(withMessage(getAdminAppPath("/portfolio"), "error", "Projekt nicht gefunden."));
redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt nicht gefunden." }));
}
await prisma.portfolioProject.delete({
@@ -600,12 +597,12 @@ export async function deleteProjectAction(formData: FormData) {
revalidatePath(getLocalizedPath(locale, `/portfolio/${project.slug}`, siteSettings.defaultLocale));
}
redirect(withMessage(getAdminAppPath("/portfolio"), "success", "Project deleted."));
redirect(withFlash(getAdminAppPath("/portfolio"), { success: "Projekt geloescht." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
redirect(withMessage(getAdminAppPath("/portfolio"), "error", "Unable to delete project."));
redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt konnte nicht geloescht werden." }));
}
}
+9 -1
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { PortfolioCategoriesManager } from "@/components/admin/portfolio-categories-manager";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminAppPath } from "@/lib/admin-routing";
import { getAdminPortfolioCategories } from "@/lib/portfolio";
@@ -23,7 +24,13 @@ const copy = {
backToSite: "Zur Website",
};
export default async function AdminPortfolioCategoriesPage() {
export default async function AdminPortfolioCategoriesPage({
searchParams,
}: {
searchParams?: Promise<{ success?: string; error?: string }>;
}) {
const flash = readFlash(await searchParams);
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
}
@@ -43,6 +50,7 @@ export default async function AdminPortfolioCategoriesPage() {
<AdminDashboardShell
copy={copy}
active="portfolio"
flash={flash}
portfolioChild="categories"
logoutAction={logoutAction}
headerTitle={copy.title}
+3
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { PortfolioProjectsOverview } from "@/components/admin/portfolio-projects-overview";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminAppPath } from "@/lib/admin-routing";
@@ -32,6 +33,7 @@ type AdminPortfolioPageProps = {
export default async function AdminPortfolioPage({ searchParams }: AdminPortfolioPageProps) {
const resolvedSearchParams = await searchParams;
const flash = readFlash(resolvedSearchParams);
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
@@ -62,6 +64,7 @@ export default async function AdminPortfolioPage({ searchParams }: AdminPortfoli
<AdminDashboardShell
copy={copy}
active="portfolio"
flash={flash}
portfolioChild="overview"
logoutAction={logoutAction}
headerTitle={copy.title}
+7 -1
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectForm } from "@/components/admin/portfolio-project-form";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
@@ -48,11 +49,15 @@ type AdminPortfolioProjectPageProps = {
params: Promise<{
id: string;
}>;
searchParams?: Promise<{ success?: string; error?: string }>;
};
export default async function AdminPortfolioProjectPage({
params,
searchParams,
}: AdminPortfolioProjectPageProps) {
const flash = readFlash(await searchParams);
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
}
@@ -73,13 +78,14 @@ export default async function AdminPortfolioProjectPage({
]);
if (!project) {
redirect(`${getAdminAppPath("/portfolio")}?error=Project+not+found.`);
redirect(getAdminAppPath("/portfolio"));
}
return (
<AdminDashboardShell
copy={copy}
active="portfolio"
flash={flash}
portfolioChild="projects"
logoutAction={logoutAction}
headerTitle={copy.title}
+9 -1
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { PortfolioProjectForm } from "@/components/admin/portfolio-project-form";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminAppPath } from "@/lib/admin-routing";
import { getMediaOptions } from "@/lib/media";
@@ -25,7 +26,13 @@ const copy = {
backToSite: "Zur Website",
};
export default async function AdminNewPortfolioProjectPage() {
export default async function AdminNewPortfolioProjectPage({
searchParams,
}: {
searchParams?: Promise<{ success?: string; error?: string }>;
}) {
const flash = readFlash(await searchParams);
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
}
@@ -46,6 +53,7 @@ export default async function AdminNewPortfolioProjectPage() {
<AdminDashboardShell
copy={copy}
active="portfolio"
flash={flash}
portfolioChild="new-project"
logoutAction={logoutAction}
headerTitle={copy.title}
+5
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { PortfolioProjectsOverview } from "@/components/admin/portfolio-projects-overview";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminAppPath } from "@/lib/admin-routing";
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
@@ -25,6 +26,8 @@ type AdminPortfolioProjectsPageProps = {
searchParams?: Promise<{
category?: string;
status?: "all" | "draft" | "published";
success?: string;
error?: string;
}>;
};
@@ -32,6 +35,7 @@ export default async function AdminPortfolioProjectsPage({
searchParams,
}: AdminPortfolioProjectsPageProps) {
const resolvedSearchParams = await searchParams;
const flash = readFlash(resolvedSearchParams);
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
@@ -62,6 +66,7 @@ export default async function AdminPortfolioProjectsPage({
<AdminDashboardShell
copy={copy}
active="portfolio"
flash={flash}
portfolioChild="projects"
logoutAction={logoutAction}
headerTitle={copy.title}
+5 -10
View File
@@ -16,6 +16,7 @@ import {
updateSiteSettings,
} from "@/lib/app-config";
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
import { withFlash } from "@/lib/admin-feedback";
import {
PAGE_TITLE_TOKEN,
normalizeSiteDefaultLocale,
@@ -38,12 +39,6 @@ async function ensureAdmin() {
}
}
function withMessage(pathname: string, type: "success" | "error", message: string) {
const params = new URLSearchParams();
params.set(type, message);
return `${pathname}?${params.toString()}`;
}
function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
if (typeof rawValue !== "string" || rawValue.trim() === "") {
@@ -260,7 +255,7 @@ export async function saveSiteBrandSettingsAction(formData: FormData) {
});
await revalidateSiteSettingsPages(parsedSettings.defaultLocale);
redirect(withMessage(getAdminAppPath("/site-settings/brand"), "success", "Einstellungen gespeichert."));
redirect(withFlash(getAdminAppPath("/site-settings/brand"), { success: "Einstellungen gespeichert." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
@@ -273,7 +268,7 @@ export async function saveSiteBrandSettingsAction(formData: FormData) {
? error.message
: "Einstellungen konnten nicht gespeichert werden.";
redirect(withMessage(getAdminAppPath("/site-settings/brand"), "error", message));
redirect(withFlash(getAdminAppPath("/site-settings/brand"), { error: message }));
}
}
@@ -323,7 +318,7 @@ export async function saveSiteLocalizationSettingsAction(formData: FormData) {
await updateSiteSettings(parsedSettings);
await revalidateSiteSettingsPages(parsedSettings.defaultLocale);
redirect(withMessage(getAdminAppPath("/site-settings/localization"), "success", "Einstellungen gespeichert."));
redirect(withFlash(getAdminAppPath("/site-settings/localization"), { success: "Einstellungen gespeichert." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
@@ -334,6 +329,6 @@ export async function saveSiteLocalizationSettingsAction(formData: FormData) {
? error.message
: "Einstellungen konnten nicht gespeichert werden.";
redirect(withMessage(getAdminAppPath("/site-settings/localization"), "error", message));
redirect(withFlash(getAdminAppPath("/site-settings/localization"), { error: message }));
}
}
+9 -1
View File
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { SiteSettingsForm } from "@/components/admin/site-settings-form";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminAppPath } from "@/lib/admin-routing";
import {
@@ -32,7 +33,13 @@ const copy = {
backToSite: "Zur Website",
};
export default async function AdminSiteBrandSettingsPage() {
export default async function AdminSiteBrandSettingsPage({
searchParams,
}: {
searchParams?: Promise<{ success?: string; error?: string }>;
}) {
const flash = readFlash(await searchParams);
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
}
@@ -54,6 +61,7 @@ export default async function AdminSiteBrandSettingsPage() {
<AdminDashboardShell
copy={copy}
active="site-settings"
flash={flash}
siteSettingsChild="brand"
logoutAction={logoutAction}
headerTitle={copy.title}
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { SiteSettingsForm } from "@/components/admin/site-settings-form";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminAppPath } from "@/lib/admin-routing";
import {
@@ -30,7 +31,13 @@ const copy = {
backToSite: "Zur Website",
};
export default async function AdminSiteLocalizationSettingsPage() {
export default async function AdminSiteLocalizationSettingsPage({
searchParams,
}: {
searchParams?: Promise<{ success?: string; error?: string }>;
}) {
const flash = readFlash(await searchParams);
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
}
@@ -48,6 +55,7 @@ export default async function AdminSiteLocalizationSettingsPage() {
<AdminDashboardShell
copy={copy}
active="site-settings"
flash={flash}
siteSettingsChild="localization"
logoutAction={logoutAction}
headerTitle={copy.title}
+5 -10
View File
@@ -6,6 +6,7 @@ import { isRedirectError } from "next/dist/client/components/redirect-error";
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { withFlash } from "@/lib/admin-feedback";
import { isCheckedFormValue } from "@/lib/form-data";
import {
getMailSettings,
@@ -21,12 +22,6 @@ async function ensureAdmin() {
}
}
function withMessage(pathname: string, type: "success" | "error", message: string) {
const params = new URLSearchParams();
params.set(type, message);
return `${pathname}?${params.toString()}`;
}
function parsePort(value: string) {
const port = Number.parseInt(value, 10);
@@ -79,7 +74,7 @@ export async function saveMailSettingsAction(formData: FormData) {
await updateMailSettings(nextMailSettings);
revalidatePath(toInternalAdminPath("/smtp"));
redirect(withMessage(getAdminAppPath("/smtp"), "success", "SMTP Einstellungen gespeichert."));
redirect(withFlash(getAdminAppPath("/smtp"), { success: "SMTP Einstellungen gespeichert." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
@@ -90,7 +85,7 @@ export async function saveMailSettingsAction(formData: FormData) {
? error.message
: "SMTP Einstellungen konnten nicht gespeichert werden.";
redirect(withMessage(getAdminAppPath("/smtp"), "error", message));
redirect(withFlash(getAdminAppPath("/smtp"), { error: message }));
}
}
@@ -99,7 +94,7 @@ export async function sendTestEmailAction() {
try {
await sendTestEmail();
redirect(withMessage(getAdminAppPath("/smtp"), "success", "Test-E-Mail gesendet."));
redirect(withFlash(getAdminAppPath("/smtp"), { success: "Test-E-Mail gesendet." }));
} catch (error) {
if (isRedirectError(error)) {
throw error;
@@ -110,6 +105,6 @@ export async function sendTestEmailAction() {
? error.message
: "Test-E-Mail konnte nicht gesendet werden.";
redirect(withMessage(getAdminAppPath("/smtp"), "error", message));
redirect(withFlash(getAdminAppPath("/smtp"), { error: message }));
}
}
@@ -1,112 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { isRedirectError } from "next/dist/client/components/redirect-error";
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { isCheckedFormValue } from "@/lib/form-data";
import {
getContactProtectionSettings,
updateContactProtectionSettings,
} from "@/lib/app-config";
import type { ContactProtectionSettings } from "@/lib/contact-protection";
async function ensureAdmin() {
if (!(await isAdminAuthenticated())) {
await clearAdminSessionCookie();
redirect(getAdminAppPath("/"));
}
}
function withMessage(pathname: string, type: "success" | "error", message: string) {
const params = new URLSearchParams();
params.set(type, message);
return `${pathname}?${params.toString()}`;
}
function parseCount(value: string, fallback: number, label: string) {
const parsed = Number.parseInt(value || String(fallback), 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${label} must be a positive number.`);
}
return parsed;
}
function parseContactProtectionFormData(
formData: FormData,
existingSettings: ContactProtectionSettings,
): ContactProtectionSettings {
const turnstileEnabled = isCheckedFormValue(formData.get("turnstileEnabled"));
const turnstileSiteKey = String(formData.get("turnstileSiteKey") ?? "").trim();
const turnstileSecretKey = String(formData.get("turnstileSecretKey") ?? "");
const rateLimitEnabled = isCheckedFormValue(formData.get("contactRateLimitEnabled"));
if (turnstileEnabled && !turnstileSiteKey) {
throw new Error("Turnstile site key is required when Turnstile is enabled.");
}
if (turnstileEnabled && !turnstileSecretKey.trim() && !existingSettings.turnstile.secretKey) {
throw new Error("Turnstile secret key is required when Turnstile is enabled.");
}
return {
turnstile: {
enabled: turnstileEnabled,
siteKey: turnstileSiteKey,
secretKey: turnstileSecretKey.trim()
? turnstileSecretKey
: existingSettings.turnstile.secretKey,
},
rateLimit: {
enabled: rateLimitEnabled,
maxRequests: parseCount(
String(formData.get("contactRateLimitMaxRequests") ?? ""),
existingSettings.rateLimit.maxRequests,
"Rate limit max requests",
),
windowMinutes: parseCount(
String(formData.get("contactRateLimitWindowMinutes") ?? ""),
existingSettings.rateLimit.windowMinutes,
"Rate limit window minutes",
),
},
};
}
export async function saveContactProtectionSettingsAction(formData: FormData) {
await ensureAdmin();
try {
const existingSettings = await getContactProtectionSettings();
const nextSettings = parseContactProtectionFormData(formData, existingSettings);
await updateContactProtectionSettings(nextSettings);
revalidatePath(toInternalAdminPath("/smtp/contact-protection"));
revalidatePath("/contact");
revalidatePath("/ar/contact");
revalidatePath("/en/contact");
redirect(
withMessage(
getAdminAppPath("/smtp/contact-protection"),
"success",
"Contact Protection gespeichert.",
),
);
} catch (error) {
if (isRedirectError(error)) {
throw error;
}
const message =
error instanceof Error
? error.message
: "Contact Protection konnte nicht gespeichert werden.";
redirect(withMessage(getAdminAppPath("/smtp/contact-protection"), "error", message));
}
}
@@ -1,62 +0,0 @@
import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { ContactProtectionForm } from "@/components/admin/contact-protection-form";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminAppPath } from "@/lib/admin-routing";
import { getContactProtectionFormValues } from "@/lib/app-config";
import { saveContactProtectionSettingsAction } from "./actions";
export const dynamic = "force-dynamic";
const copy = {
title: "Contact Protection",
subtitle: "Turnstile und Rate Limiting fuer das Kontaktformular.",
overview: "Uebersicht",
maintenance: "Wartungsmodus",
uiKit: "UI Kit",
media: "Media",
siteSettings: "Settings",
smtp: "SMTP",
contactProtection: "Contact Protection",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
};
export default async function AdminSMTPProtectionPage() {
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
}
async function logoutAction() {
"use server";
await clearAdminSessionCookie();
redirect(getAdminAppPath("/"));
}
const settings = await getContactProtectionFormValues();
return (
<AdminDashboardShell
copy={copy}
active="smtp"
smtpChild="contact-protection"
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
>
<div className="space-y-6">
<MotionFade delay={0.16}>
<ContactProtectionForm
action={saveContactProtectionSettingsAction}
initialSettings={settings}
/>
</MotionFade>
</div>
</AdminDashboardShell>
);
}
+9 -3
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { SMTPSettingsForm } from "@/components/admin/smtp-settings-form";
import { Button } from "@/components/ui/button";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
@@ -21,13 +22,18 @@ const copy = {
media: "Media",
siteSettings: "Settings",
smtp: "SMTP",
contactProtection: "Contact Protection",
portfolio: "Portfolio",
logout: "Ausloggen",
backToSite: "Zur Website",
};
export default async function AdminSMTPPage() {
export default async function AdminSMTPPage({
searchParams,
}: {
searchParams?: Promise<{ success?: string; error?: string }>;
}) {
const flash = readFlash(await searchParams);
if (!(await isAdminAuthenticated())) {
redirect(getAdminAppPath("/"));
}
@@ -45,7 +51,7 @@ export default async function AdminSMTPPage() {
<AdminDashboardShell
copy={copy}
active="smtp"
smtpChild="settings"
flash={flash}
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
+9 -1
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { MotionFade } from "@/components/motion-fade";
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
import { readFlash } from "@/lib/admin-feedback";
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
import { getAdminAppPath } from "@/lib/admin-routing";
import { UiKitShowcase } from "@/components/ui/ui-kit-showcase";
@@ -21,7 +22,13 @@ const copy = {
backToSite: "Zur Website",
};
export default async function AdminUiKitPage() {
export default async function AdminUiKitPage({
searchParams,
}: {
searchParams?: Promise<{ success?: string; error?: string }>;
}) {
const flash = readFlash(await searchParams);
const authenticated = await isAdminAuthenticated();
if (!authenticated) {
@@ -39,6 +46,7 @@ export default async function AdminUiKitPage() {
<AdminDashboardShell
copy={copy}
active="ui-kit"
flash={flash}
logoutAction={logoutAction}
headerTitle={copy.title}
headerDescription={copy.subtitle}
@@ -1 +0,0 @@
export { default } from "../../../_admin/smtp/contact-protection/page";
-4
View File
@@ -3,10 +3,8 @@ import { unstable_noStore as noStore } from "next/cache";
import localFont from "next/font/local";
import Script from "next/script";
import { getLocale } from "next-intl/server";
import { QueryToastBridge } from "@/components/admin/query-toast-bridge";
import { SoundProvider } from "@/components/sound-provider";
import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/toaster";
import { getSiteSettings } from "@/lib/app-config";
import { buildAppMetadata } from "@/lib/metadata";
import { getDirection } from "@/lib/locale";
@@ -96,8 +94,6 @@ export default async function RootLayout({
<ThemeProvider>
<SoundProvider>
{children}
<QueryToastBridge />
<Toaster locale={locale} />
</SoundProvider>
</ThemeProvider>
<Script
@@ -1 +0,0 @@
export { default } from "../../../_admin/smtp/contact-protection/page";
+10 -8
View File
@@ -15,6 +15,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { AdminFlash } from "@/components/admin/admin-flash";
import { DashboardLayout } from "@/components/dashboard/dashboard-layout";
import { MotionFade } from "@/components/motion-fade";
import { SidebarMaintenanceControl } from "@/components/admin/sidebar-maintenance-control";
@@ -24,6 +25,7 @@ import { Button } from "@/components/ui/button";
import { buildSiteUrl, getAdminAppPath } from "@/lib/admin-routing";
import { getMaintenanceMode, getSiteSettingsMediaBindings } from "@/lib/app-config";
import { getAdminNavigation } from "@/lib/admin-navigation";
import type { FlashMessages } from "@/lib/admin-feedback";
import { updateMaintenanceModeAction } from "@/app/_admin/maintenance/actions";
@@ -40,7 +42,6 @@ type AdminDashboardCopy = {
localizationSettings?: string;
marquee?: string;
smtp?: string;
contactProtection?: string;
logout: string;
backToSite: string;
};
@@ -48,9 +49,9 @@ type AdminDashboardCopy = {
type AdminDashboardShellProps = {
copy: AdminDashboardCopy;
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
smtpChild?: "settings" | "contact-protection";
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
siteSettingsChild?: "brand" | "localization";
flash?: FlashMessages;
logoutAction: () => Promise<void>;
headerTitle: string;
headerDescription: string;
@@ -63,9 +64,9 @@ type AdminDashboardShellProps = {
export async function AdminDashboardShell({
copy,
active,
smtpChild,
portfolioChild,
siteSettingsChild,
flash,
logoutAction,
headerTitle,
headerDescription,
@@ -78,7 +79,7 @@ export async function AdminDashboardShell({
getSiteSettingsMediaBindings(),
getMaintenanceMode(),
]);
const sidebarItems = getAdminNavigation(copy, active, smtpChild, portfolioChild, siteSettingsChild);
const sidebarItems = getAdminNavigation(copy, active, portfolioChild, siteSettingsChild);
const normalizedSidebarItems = sidebarItems.filter(
(item) =>
item.href !== getAdminAppPath("/maintenance") &&
@@ -155,18 +156,19 @@ export async function AdminDashboardShell({
<SoundToggle
ariaLabel="Mute sounds"
mutedAriaLabel="Unmute sounds"
mutedToastLabel="Sound muted"
unmutedToastLabel="Sound enabled"
/>
<ThemeToggle
ariaLabel="Theme wechseln"
lightToastLabel="Light mode enabled"
darkToastLabel="Dark mode enabled"
/>
</div>
}
>
<div className="space-y-6">
{flash?.success || flash?.error ? (
<MotionFade delay={0.05}>
<AdminFlash success={flash.success} error={flash.error} />
</MotionFade>
) : null}
{toolbar ? <MotionFade delay={0.05}>{toolbar}</MotionFade> : null}
{children}
</div>
+38
View File
@@ -0,0 +1,38 @@
import type { FlashMessages } from "@/lib/admin-feedback";
type AdminFlashProps = FlashMessages & {
className?: string;
};
/**
* Inline feedback banner for admin pages. Renders a success and/or error
* message inside the page (replacing the removed toast system). Purely
* presentational — no client state, no data access.
*/
export function AdminFlash({ success, error, className }: AdminFlashProps) {
if (!success && !error) {
return null;
}
return (
<div className={className ? `space-y-2 ${className}` : "space-y-2"}>
{success ? (
<p
role="status"
aria-live="polite"
className="rounded-nested border border-status-success/30 bg-status-success-soft px-4 py-3 text-sm font-medium text-status-success"
>
{success}
</p>
) : null}
{error ? (
<p
role="alert"
className="rounded-nested border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm font-medium text-destructive"
>
{error}
</p>
) : null}
</div>
);
}
@@ -1,137 +0,0 @@
"use client";
import { useState } from "react";
import { AppCard } from "@/components/ui/app-card";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ContactProtectionFormValues } from "@/lib/contact-protection";
type ContactProtectionFormProps = {
action: (formData: FormData) => Promise<void>;
initialSettings: ContactProtectionFormValues;
};
export function ContactProtectionForm({
action,
initialSettings,
}: ContactProtectionFormProps) {
const [turnstileEnabled, setTurnstileEnabled] = useState(initialSettings.turnstile.enabled);
const [rateLimitEnabled, setRateLimitEnabled] = useState(initialSettings.rateLimit.enabled);
return (
<form id="contact-protection-form" action={action} className="grid gap-6 xl:grid-cols-2">
<AppCard layer="single">
<CardHeader>
<CardTitle>Turnstile</CardTitle>
<CardDescription>Cloudflare Schutz fuer das Kontaktformular.</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 p-4 md:grid-cols-2">
<div className="space-y-4 md:col-span-2">
<label
htmlFor="turnstileEnabled"
className="flex cursor-pointer items-start gap-3 rounded-nested border border-input bg-card px-4 py-4 transition-colors hover:bg-accent/20"
>
<Checkbox
id="turnstileEnabled"
name="turnstileEnabled"
checked={turnstileEnabled}
onCheckedChange={(checked) => setTurnstileEnabled(checked === true)}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block text-sm font-medium text-foreground">Enable Turnstile</span>
<span className="block text-xs text-muted-foreground">
{turnstileEnabled
? "ON: Besucher muessen die Pruefung bestehen."
: "OFF: Kein Turnstile Schutz im Formular."}
</span>
</span>
</label>
</div>
<div className="space-y-2">
<Label htmlFor="turnstileSiteKey">Turnstile Site Key</Label>
<Input
id="turnstileSiteKey"
name="turnstileSiteKey"
defaultValue={initialSettings.turnstile.siteKey}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="turnstileSecretKey">Turnstile Secret Key</Label>
<Input
id="turnstileSecretKey"
name="turnstileSecretKey"
type="password"
defaultValue={initialSettings.turnstile.secretKey}
placeholder={initialSettings.turnstile.hasSecretKey ? "Saved secret key will be kept" : "Secret key"}
autoComplete="new-password"
/>
</div>
</CardContent>
</AppCard>
<AppCard layer="single">
<CardHeader>
<CardTitle>Rate Limiting</CardTitle>
<CardDescription>Begrenzung wiederholter Kontaktanfragen.</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 p-4 md:grid-cols-2">
<div className="space-y-4 md:col-span-2">
<label
htmlFor="contactRateLimitEnabled"
className="flex cursor-pointer items-start gap-3 rounded-nested border border-input bg-card px-4 py-4 transition-colors hover:bg-accent/20"
>
<Checkbox
id="contactRateLimitEnabled"
name="contactRateLimitEnabled"
checked={rateLimitEnabled}
onCheckedChange={(checked) => setRateLimitEnabled(checked === true)}
className="mt-0.5"
/>
<span className="space-y-1">
<span className="block text-sm font-medium text-foreground">Enable rate limiting</span>
<span className="block text-xs text-muted-foreground">
{rateLimitEnabled
? "ON: Das Formular blockiert zu viele Anfragen pro Zeitfenster."
: "OFF: Keine serverseitige Begrenzung aktiv."}
</span>
</span>
</label>
</div>
<div className="space-y-2">
<Label htmlFor="contactRateLimitMaxRequests">Max Requests</Label>
<Input
id="contactRateLimitMaxRequests"
name="contactRateLimitMaxRequests"
type="number"
min="1"
defaultValue={initialSettings.rateLimit.maxRequests}
/>
</div>
<div className="space-y-2">
<Label htmlFor="contactRateLimitWindowMinutes">Window Minutes</Label>
<Input
id="contactRateLimitWindowMinutes"
name="contactRateLimitWindowMinutes"
type="number"
min="1"
defaultValue={initialSettings.rateLimit.windowMinutes}
/>
</div>
</CardContent>
</AppCard>
<div className="xl:col-span-2 flex justify-end">
<Button type="submit">Save Protection</Button>
</div>
</form>
);
}
-86
View File
@@ -1,86 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { toast } from "@/lib/toast";
const TOAST_PARAM_NAMES = ["success", "error"] as const;
const PENDING_TOAST_STORAGE_KEY = "mohfarawati-pending-toast";
export function QueryToastBridge() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const handledKeyRef = useRef<string | null>(null);
useEffect(() => {
const rawToast = window.sessionStorage.getItem(PENDING_TOAST_STORAGE_KEY);
if (!rawToast) {
return;
}
try {
const pendingToast = JSON.parse(rawToast) as {
message?: string;
type?: "success" | "error";
};
const localizedMessage = pendingToast.message;
if (!localizedMessage) {
return;
}
if (pendingToast.type === "error") {
toast.error(localizedMessage);
} else if (pendingToast.type === "success") {
toast.success(localizedMessage);
} else {
toast(localizedMessage);
}
} finally {
window.sessionStorage.removeItem(PENDING_TOAST_STORAGE_KEY);
}
}, [pathname]);
useEffect(() => {
const successMessage = searchParams.get("success");
const errorMessage = searchParams.get("error");
if (!successMessage && !errorMessage) {
handledKeyRef.current = null;
return;
}
const handledKey = `${pathname}:${successMessage ?? ""}:${errorMessage ?? ""}`;
if (handledKeyRef.current === handledKey) {
return;
}
handledKeyRef.current = handledKey;
if (successMessage) {
toast.success(successMessage);
}
if (errorMessage) {
toast.error(errorMessage);
}
const nextParams = new URLSearchParams(searchParams.toString());
for (const name of TOAST_PARAM_NAMES) {
nextParams.delete(name);
}
const nextQuery = nextParams.toString();
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, {
scroll: false,
});
}, [pathname, router, searchParams]);
return null;
}
@@ -27,15 +27,11 @@ export function FloatingPreferences({
<SoundToggle
ariaLabel={t("soundMute")}
mutedAriaLabel={t("soundUnmute")}
mutedToastLabel={t("soundMuted")}
unmutedToastLabel={t("soundEnabled")}
variant="ghost"
className="h-9 w-9 rounded-pill border border-transparent bg-transparent text-foreground/80 hover:bg-accent hover:text-foreground"
/>
<ThemeToggle
ariaLabel={t("themeToggle")}
lightToastLabel={t("themeLight")}
darkToastLabel={t("themeDark")}
variant="ghost"
className="h-9 w-9 rounded-pill border border-transparent bg-transparent text-foreground/80 hover:bg-accent hover:text-foreground"
/>
-24
View File
@@ -13,8 +13,6 @@ import {
import { AppLocale, getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
import { cn } from "@/lib/utils";
const PENDING_TOAST_STORAGE_KEY = "mohfarawati-pending-toast";
type LocaleToggleProps = {
locale: string;
defaultLocale: AppLocale;
@@ -22,12 +20,6 @@ type LocaleToggleProps = {
showLabel?: boolean;
};
const localeChangedMessages: Record<AppLocale, string> = {
de: "Sprache auf Deutsch gewechselt",
en: "Language changed to English",
ar: "تم تغيير اللغة إلى العربية",
};
export function LocaleToggle({
locale,
defaultLocale,
@@ -85,22 +77,6 @@ export function LocaleToggle({
>
<a
href={getLocalizedPath(targetLocale, currentPath, defaultLocale)}
onClick={() => {
const message = localeChangedMessages[targetLocale];
if (!message) {
return;
}
window.sessionStorage.setItem(
PENDING_TOAST_STORAGE_KEY,
JSON.stringify({
type: "success",
message,
targetLocale,
}),
);
}}
className="group flex h-10 w-10 items-center justify-center rounded-full"
>
<Image
-10
View File
@@ -15,7 +15,6 @@ import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { buildAdminUrl } from "@/lib/admin-routing";
import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
const navItems = [
@@ -211,7 +210,6 @@ export function SiteHeader({
logoClickTimesRef.current = [];
event.preventDefault();
toast(t("logoTripleClick"));
};
return (
@@ -317,15 +315,11 @@ export function SiteHeader({
<SoundToggle
ariaLabel={t("soundMute")}
mutedAriaLabel={t("soundUnmute")}
mutedToastLabel={t("soundMuted")}
unmutedToastLabel={t("soundEnabled")}
variant="ghost"
className={desktopControlButtonClassName}
/>
<ThemeToggle
ariaLabel={t("themeToggle")}
lightToastLabel={t("themeLight")}
darkToastLabel={t("themeDark")}
variant="ghost"
className={desktopControlButtonClassName}
/>
@@ -467,15 +461,11 @@ export function SiteHeader({
<SoundToggle
ariaLabel={t("soundMute")}
mutedAriaLabel={t("soundUnmute")}
mutedToastLabel={t("soundMuted")}
unmutedToastLabel={t("soundEnabled")}
variant="ghost"
className={mobileControlButtonClassName}
/>
<ThemeToggle
ariaLabel={t("themeToggle")}
lightToastLabel={t("themeLight")}
darkToastLabel={t("themeDark")}
variant="ghost"
className={mobileControlButtonClassName}
/>
-8
View File
@@ -2,12 +2,10 @@
import Link from "next/link";
import { ContactTurnstile } from "@/components/site/contact-turnstile";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import type { PublicContactProtectionSettings } from "@/lib/contact-protection";
type ContactFormCopy = {
name: string;
@@ -25,7 +23,6 @@ type ContactFormProps = {
locale: string;
previewHref: string;
copy: ContactFormCopy;
protection: PublicContactProtectionSettings;
};
export function ContactForm({
@@ -33,7 +30,6 @@ export function ContactForm({
locale,
previewHref,
copy,
protection,
}: ContactFormProps) {
return (
<form action={action} className="grid gap-5">
@@ -68,10 +64,6 @@ export function ContactForm({
<p className="text-sm text-muted-foreground">{copy.note}</p>
{protection.turnstile.enabled ? (
<ContactTurnstile siteKey={protection.turnstile.siteKey} />
) : null}
<div className="flex flex-wrap gap-3">
<Button type="submit">{copy.submit}</Button>
<Button asChild variant="outline">
-107
View File
@@ -1,107 +0,0 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
declare global {
interface Window {
turnstile?: {
render: (
container: HTMLElement,
options: {
sitekey: string;
callback?: (token: string) => void;
"expired-callback"?: () => void;
"error-callback"?: () => void;
},
) => string;
};
}
}
type ContactTurnstileProps = {
siteKey: string;
};
export function ContactTurnstile({ siteKey }: ContactTurnstileProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [token, setToken] = useState("");
const [error, setError] = useState("");
const widgetId = useId();
useEffect(() => {
if (!siteKey || !containerRef.current) {
return undefined;
}
let cancelled = false;
function renderWidget() {
if (cancelled || !containerRef.current || !window.turnstile) {
return;
}
if (containerRef.current.dataset.widgetMounted === widgetId) {
return;
}
containerRef.current.innerHTML = "";
window.turnstile.render(containerRef.current, {
sitekey: siteKey,
callback: (nextToken) => {
setToken(nextToken);
setError("");
},
"expired-callback": () => {
setToken("");
setError("Verification expired. Please try again.");
},
"error-callback": () => {
setToken("");
setError("Verification could not be completed.");
},
});
containerRef.current.dataset.widgetMounted = widgetId;
}
function ensureScript() {
const existingScript = document.querySelector<HTMLScriptElement>(
'script[src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"]',
);
if (existingScript) {
if (window.turnstile) {
renderWidget();
} else {
existingScript.addEventListener("load", renderWidget, { once: true });
}
return;
}
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.defer = true;
script.addEventListener("load", renderWidget, { once: true });
document.head.appendChild(script);
}
ensureScript();
return () => {
cancelled = true;
};
}, [siteKey, widgetId]);
if (!siteKey) {
return null;
}
return (
<div className="space-y-2">
<div ref={containerRef} />
<input type="hidden" name="turnstileToken" value={token} />
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
);
}
-6
View File
@@ -3,14 +3,11 @@
import { Volume2, VolumeX } from "lucide-react";
import { useSound } from "@/components/sound-provider";
import { Button, type ButtonProps } from "@/components/ui/button";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
type SoundToggleProps = {
ariaLabel?: string;
mutedAriaLabel?: string;
mutedToastLabel?: string;
unmutedToastLabel?: string;
variant?: ButtonProps["variant"];
className?: string;
iconClassName?: string;
@@ -19,8 +16,6 @@ type SoundToggleProps = {
export function SoundToggle({
ariaLabel = "Mute sounds",
mutedAriaLabel = "Unmute sounds",
mutedToastLabel = "Sound muted",
unmutedToastLabel = "Sound enabled",
variant = "outline",
className,
iconClassName,
@@ -29,7 +24,6 @@ export function SoundToggle({
const handleToggle = () => {
toggleMuted();
toast(isMuted ? unmutedToastLabel : mutedToastLabel);
};
return (
-6
View File
@@ -5,14 +5,11 @@ import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { useSound } from "@/components/sound-provider";
import { Button, type ButtonProps } from "@/components/ui/button";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
type ThemeToggleProps = {
ariaLabel?: string;
label?: string;
lightToastLabel?: string;
darkToastLabel?: string;
variant?: ButtonProps["variant"];
className?: string;
iconClassName?: string;
@@ -21,8 +18,6 @@ type ThemeToggleProps = {
export function ThemeToggle({
ariaLabel = "Toggle theme",
label,
lightToastLabel = "Light mode enabled",
darkToastLabel = "Dark mode enabled",
variant = "outline",
className,
iconClassName,
@@ -58,7 +53,6 @@ export function ThemeToggle({
setTheme(nextTheme);
playSound(nextTheme === "dark" ? "/audio/dark.mp3" : "/audio/light.mp3");
toast(nextTheme === "dark" ? darkToastLabel : lightToastLabel);
};
return (
-32
View File
@@ -1,32 +0,0 @@
"use client";
import { Toaster as HotToaster, type ToasterProps } from "react-hot-toast";
type AppToasterProps = ToasterProps & {
locale: string;
};
export function Toaster({ locale, ...props }: AppToasterProps) {
const isArabic = locale === "ar";
const toastClassName = `${isArabic ? "font-arabic tracking-normal" : "font-latin"} text-[13px] leading-5`;
return (
<HotToaster
key={locale}
{...props}
position={props.position ?? (isArabic ? "top-right" : "top-left")}
reverseOrder={props.reverseOrder ?? false}
toastOptions={{
...props.toastOptions,
className:
props.toastOptions?.className ??
toastClassName,
style: {
borderRadius: "36px",
...(props.toastOptions?.style ?? {}),
},
}}
/>
);
}
+1 -8
View File
@@ -73,9 +73,6 @@ Handles media library queries and media bindings.
lib/mail.ts
Handles SMTP delivery via nodemailer.
lib/contact-guard.ts
Handles Turnstile verification and rate limiting for contact submissions.
These modules act as the primary server-side application layer.
---
@@ -149,11 +146,7 @@ Database queries should not be implemented directly inside route-level UI files.
app/[locale]/(site)/contact/page.tsx
2. Server action validates the input using Zod.
3. Turnstile verification and rate limiting run via:
lib/contact-guard.ts
4. Email is sent through:
3. Email is sent through:
lib/mail.ts
+1 -3
View File
@@ -49,9 +49,7 @@
### Current implementation
- Contact submission requires valid name, email, and message
- Turnstile is optional and controlled by settings
- Rate limiting is optional and keyed by hashed client IP plus time window
- Contact submission requires valid name, email, and message (validated with Zod)
- Successful submission sends email only
- No submission record is stored in the database
-3
View File
@@ -13,8 +13,6 @@
- `CASE_STUDY`
- Contact form with:
- validation
- optional Turnstile
- rate limiting
- email delivery
- Success page after contact submission
- Maintenance redirect flow
@@ -29,7 +27,6 @@
- Media library with usage bindings
- Site settings management
- SMTP settings and test email
- Contact protection settings
- Marquee settings
- Maintenance toggle
- UI Kit preview page
+148
View File
@@ -0,0 +1,148 @@
# تقرير المرحلة 0 — الجرد والتشخيص
> تقرير فقط، بدون أي تعديل على الكود. لا يبدأ أي تنفيذ قبل موافقتك.
> التاريخ: 2026-07-14
---
## خلاصة سريعة
البنية التقنية والـ backend سليمة فعلاً كما ذكرت — لا تحتاج إعادة كتابة. المشاكل الحقيقية في طبقتين:
1. **طبقة العرض/التجربة (UI/UX):** الصفحة الرئيسية والـ views الثلاثة تشترك في مظهر واحد رتيب (كروت ناعمة على سطح رمادي)، وأهم عيب أن **البورتفوليو بلا صور** في الصفحة الرئيسية وصفحة قائمة الأعمال — وهذا قاتل لموقع مصمم.
2. **الوثائق:** الملفات الأساسية دقيقة، لكن ملفات `frontend-system-*.md` الثلاثة قديمة (تشير إلى مسارات محذوفة).
كما رصدت تعقيدات على طراز الـ SaaS زائدة عن حاجة موقع شخصي، أهمها: نظام الـ Toast، بنية الأدمن الثلاثية، حماية نموذج التواصل (rate-limit + Turnstile)، وطبقتا مصادقة للأدمن.
---
## 0.2 — حالة الوثائق (`docs/` و `specs/`) مقابل الكود
### دقيقة ومطابقة للكود ✅
| الملف | الحالة |
|---|---|
| `docs/ARCHITECTURE.md` | دقيق (ملاحظة: يسمّي الملف `middleware` لكنه فعلياً `proxy.ts` بعد ترقية Next.js 16) |
| `docs/DOMAIN_RULES.md` | دقيق — قواعد البورتفوليو والميديا والتواصل كلها مطابقة للـ schema |
| `docs/PROJECT_OVERVIEW.md` | دقيق (ينقصه ذكر بعض المسارات) |
| `docs/FEATURES.md` | دقيق لكنه ناقص (لا يذكر الاختبارات الموجودة فعلاً ولا نظام الصوت) |
| كل ملفات `specs/*.md` السبعة | دقيقة وصادقة في التمييز بين المنفَّذ والمقترح |
### قديمة أو خاطئة ❌ (تحتاج تصحيح في المرحلة 1)
الملفات الثلاثة `docs/frontend-system-audit.md`، `frontend-system-current-state.md`، `frontend-system-refactor-summary.md` كُتبت قبل إعادة تسمية مجلد، فصارت تشير إلى مسارات غير موجودة:
- `components/root/*` ← الصحيح `components/admin/*`
- `lib/root-navigation.ts` ← الصحيح `lib/admin-navigation.ts`
- تدّعي وجود مجلد `src/` — وهو غير موجود أصلاً
المحتوى المفاهيمي فيها (التوكنز، الحركة، الأيقونات) ما زال صحيحاً؛ المشكلة في المسارات فقط.
### ميزات موجودة في الكود لكنها غير موثّقة
- نظام الصوت: `components/sound-provider.tsx` + `sound-toggle.tsx`
- مسارات API: `app/api/health` و `app/api/site/default-locale`
- خط رفع/تقديم الميديا المحلي: `app/uploads/media/[...segments]/route.ts`
- صفحة `portfolio/category` الفهرسية، وصفحات إعدادات الموقع الفرعية (brand / localization)
---
## 0.3 و 0.4 — الـ Views الثلاثة والصفحة الرئيسية
المقصود بـ «3 views» على الأرجح قوالب الصفحات الرئيسية الثلاثة:
### View 1 — الصفحة الرئيسية (`app/[locale]/(site)/page.tsx`)
**البنية:** hero بطول الشاشة (نص + تدرّج لوني فقط) ← ثم 6 أقسام متتالية (Bento، Marquee، Projects، Capabilities، Process، Contact CTA) كلها مبنية على نفس نمط `SectionHeading` + شبكة كروت `AppCard` متطابقة.
**لماذا ضعيفة:**
- **لا مفهوم ولا صور إطلاقاً** — الـ hero نص وتدرّج فقط، ولا توجد أي صورة عمل في الصفحة كلها. حتى كروت المشاريع المميزة نصية بلا صور مصغّرة.
- **رتابة وتكرار** — خمسة من ستة أقسام نفس النمط البصري تماماً، لا إيقاع ولا تمييز بينها.
- **تسلسل هرمي ضعيف** — بعد hero طويل يصطدم النظر بجدار كروت متساوية الوزن، ولا شيء يقول «هذا الأهم». الـ CTA الوحيد مدفون في الأسفل.
- **محتوى عام** («Core stack»، «Current focus») يشبه صفحة «about» أكثر من واجهة مصمم.
### View 2 — قائمة الأعمال (`portfolio/page.tsx`)
hero مضغوط + فلتر تصنيفات + شبكة `PortfolioProjectGrid` بعمودين. صفحة التصنيف `category/[slug]` مطابقة حرفياً لها.
**لماذا ضعيفة:**
- **بلا صور بتاتاً** — الكروت 100% نص رغم أن البيانات تحتوي `coverImagePath`. هذه أكبر مشكلة: القائمة التي يُفترض أن تبيع العمل لا تعرض منه شيئاً. يشبه فهرس مدوّنة لا بورتفوليو.
- تخطيط مسطّح بعمودين، كروت متساوية، بلا تمييز أو معاينة عند المرور.
### View 3 — تفاصيل المشروع (`portfolio/[slug]/page.tsx`)
`PageHero` **فارغ** (بلا عنوان) ثم `PortfolioProjectDetail` الذي يتفرّع لثلاثة قوالب: `GRID` / `STORY` / `CASE_STUDY`.
**لماذا ضعيفة:**
- **hero فارغ** يهدر أعلى الصفحة بمساحة تدرّج فارغة؛ العنوان الحقيقي يظهر لاحقاً داخل كرت.
- **كل شيء محبوس داخل كروت** — الصور والنصوص والمعرض كلها في كروت مدوّرة على سطح، بلا صورة غلاف ممتدة (full-bleed) ولا عرض غامر. الصور مقصوصة بارتفاعات ثابتة.
- **ثلاثة قوالب بمظهر واحد** — رغم تعقيد الكود، النتيجة البصرية متشابهة.
### حالة المعرض (Gallery)
يوجد معرض فعلاً لكن **داخل صفحة التفاصيل فقط** (`AssetGallery`). لا يوجد أي معرض/صور في الصفحة الرئيسية ولا في قائمة الأعمال — وهذا هو أساس ملاحظتك «لا يوجد gallery».
### النظام البصري الحالي
- ألوان: متغيرات HSL بثيم فاتح/داكن، لون العلامة أحمر-برتقالي `9 73° 50%`.
- زوايا: مدوّرة وناعمة جداً (`radius-surface: 24px`) — تعزّز مظهر الكروت الموحّد.
- خطوط: `Museo Sans Rounded` (لاتيني) + `Dubai` (عربي).
- **العيب على مستوى النظام:** كفؤ تقنياً لكنه يُنتج نسيجاً واحداً في كل مكان (كرت ناعم على سطح رمادي + لمسة برتقالية) — يشبه ثيم لوحة تحكم SaaS طُبِّق على بورتفوليو، لا هوية بصرية مبنية للبورتفوليو أولاً.
---
## 0.5 — نظام الـ Toast/الإشعارات (جاهز للإزالة)
المكتبة: `react-hot-toast` (لا Radix toast ولا Sonner).
**البنية الأساسية (مرشحة للحذف):**
- `lib/toast.tsx` — الغلاف حول react-hot-toast
- `components/ui/toaster.tsx` — مضيف العرض الوحيد
- `components/admin/query-toast-bridge.tsx` — يقرأ `?success=`/`?error=` و sessionStorage ويطلق الـ toast ثم ينظّف الرابط
- نقطة التركيب: `app/layout.tsx` (سطور 6، 9، 99-100)
- التبعية: `react-hot-toast` في `package.json`
**مواضع الاستدعاء:** toggle الثيم والصوت، تبديل اللغة، easter egg للنقر الثلاثي على الشعار، وكل أوامر الأدمن (media, site-settings, portfolio, smtp, marquee, maintenance) عبر `?success=`/`?error=`، ونموذج التواصل.
**⚠️ تحذير مهم — الـ toast هو قناة التغذية الراجعة الوحيدة في:**
1. **كل عمليات الأدمن (CRUD)** — الحفظ/الحذف/الخطأ تظهر فقط عبر الـ toast. إزالته دون بديل تترك كل عملية بلا أي تأكيد أو رسالة خطأ.
2. **أخطاء دخول الأدمن** (`locked`/`invalid`) — تصبح صامتة.
3. **أخطاء نموذج التواصل** (تحقق، rate-limit، Turnstile) — تعتمد كلياً على الـ toast.
**الخلاصة:** إزالة الـ toast من العناصر التجميلية (toggles، easter egg) آمنة تماماً. أما أوامر الأدمن ونموذج التواصل فتحتاج **بديلاً بسيطاً للتغذية الراجعة** (رسالة inline داخل الصفحة) قبل الإزالة، وإلا ستصبح العمليات صامتة. النجاح في نموذج التواصل محمي لأنه يحوّل لصفحة `/success` مستقلة.
---
## 0.6 — تعقيدات SaaS زائدة (مقترحة للإزالة، بالأولوية)
اسم المشروع نفسه `sass-mohfarawati` يكشف الأصل: بُني كأنه منتج SaaS متعدد المستخدمين، بينما هو موقع شخصي بمالك واحد.
| # | البند | لماذا زائد | خطر الإزالة |
|---|---|---|---|
| P1 | **بنية الأدمن الثلاثية** (`_admin` + `admin-internal` + `/root`) — نحو 19 ملف re-export + منطق توجيه في `lib/admin-routing.ts` و`proxy.ts` | موقع بمالك واحد يحتاج مسار أدمن واحد خلف كلمة مرور، لا توجيه بمستوى white-label | متوسط |
| P2 | **الإعدادات في قاعدة البيانات** (`AppConfig` + `lib/app-config.ts` 310 سطر) تخزّن كل شيء: إعدادات، SMTP، maintenance، عدّادات rate-limit، قفل الدخول | معظمها ثابت ويصلح كمتغيرات بيئة/ملف إعداد بدل لوحة تحكم | متوسط |
| P3 | **حماية نموذج التواصل** (`contact-guard.ts` + `contact-protection.ts` + شاشة أدمن): rate-limit لكل IP + Turnstile | دفاع ضد الإساءة لنموذج عالي الحركة؛ بورتفوليو شخصي يكفيه `zod` + honeypot | منخفض |
| P4 | **قفل دخول الأدمن ضد التخمين** (`admin-auth.ts` 309 سطر) + طبقة Basic Auth ثانية في `proxy.ts` | طبقتا مصادقة لمستخدم واحد؛ كوكي موقّع واحد يكفي | متوسط |
| P5 | **مواصفات ميزات SaaS ميتة**: `specs/orders.md`, `products.md`, `downloads.md`, `project-inquiry.md` | مجرد وثائق لميزات تجارية غير مبنية أصلاً | منخفض (حذف وثائق) |
| P6 | **مكتبة ميديا مع تتبّع الاستخدام** (`MediaAsset` + `MediaUsage` polymorphic + 4 وحدات lib) | ميزة CMS للفرق؛ المالك الواحد يربط الصور مباشرة بالمشاريع | **مرتفع** (مربوطة بالبورتفوليو والإعدادات) |
| P7 | **ثيم/ماركي قابلان للتعديل وقت التشغيل** (`site-theme.ts`, `marquee-settings.ts` + شاشات أدمن) | تخصيص للعميل؛ المالك يثبّت لونه ونصوصه في الكود | متوسط |
| P8 | **Docker/Postgres + طلب middleware ذاتي** (`proxy.ts` يستدعي `/api/site/default-locale` على نفسه في كل طلب) | ثقيل لبورتفوليو؛ الـ self-fetch نمط مضاد نتج عن P2 | متوسط-مرتفع |
**نقطة إيجابية:** لا توجد جداول Order/Product/Download/Analytics/AuditLog/User-roles في قاعدة البيانات — هذه بقيت مواصفات فقط (P5). النماذج الأساسية (`Category`, `PortfolioProject`, `PortfolioSection`, `PortfolioAsset`) شرعية وسليمة.
**ترتيب مقترح للإزالة (من الأسهل):** P5 (حذف وثائق) → P3 → Toast → P1 → P4 → P2/P8 → لاحقاً الأعلى خطراً P6/P7.
---
## القرارات المطلوبة منك قبل بدء المرحلة 1
المرحلة 0 اكتملت. لن أعدّل أي شيء قبل موافقتك. أحتاج قرارك في:
1. **نظام Toast:** الإزالة متفق عليها. هل تريدني أستبدله برسائل inline بسيطة داخل صفحات الأدمن والتواصل (موصى به لتفادي عمليات صامتة)، أم إزالة تامة بلا بديل؟
2. **تعقيدات SaaS (P1P8):** أيها توافق على إزالته الآن؟ اقتراحي أن نبدأ بالآمن (P5, P3, P8-self-fetch) ونؤجل الأعلى خطراً (P6 مكتبة الميديا، P1 بنية الأدمن) لجلسة منفصلة.
3. **تصحيح الوثائق القديمة** (`frontend-system-*.md`): موافقة على تحديث المسارات؟
-1
View File
@@ -92,7 +92,6 @@ navigation item marked as
- Site Settings
- Marquee Settings
- SMTP Settings
- Contact Protection
- Portfolio management
### Data model
+20 -20
View File
@@ -140,7 +140,7 @@ Patterns:
Repeated in:
- `components/dashboard/dashboard-layout.tsx`
- `components/root/root-dashboard-shell.tsx`
- `components/admin/root-dashboard-shell.tsx`
- many `app/root/*/page.tsx`
Patterns:
@@ -160,8 +160,8 @@ Patterns:
Repeated in:
- `components/root/site-settings-form.tsx`
- `components/root/portfolio-project-form.tsx`
- `components/admin/site-settings-form.tsx`
- `components/admin/portfolio-project-form.tsx`
- `app/root/page.tsx`
Patterns:
@@ -204,7 +204,7 @@ Most repeated token usage:
- `components/ui/textarea.tsx`
- `components/ui/select.tsx`
- `components/dashboard/sidebar.tsx`
- `components/root/contact-protection-form.tsx`
- `components/admin/contact-protection-form.tsx`
- `rounded-[var(--radius-pill)]`
- `components/layout/site-header.tsx`
- `components/layout/floating-preferences.tsx`
@@ -220,13 +220,13 @@ Most repeated token usage:
- several content blocks and previews
- examples:
- `app/root/page.tsx`
- `components/root/site-settings-form.tsx`
- `components/root/media-field-picker.tsx`
- `components/admin/site-settings-form.tsx`
- `components/admin/media-field-picker.tsx`
- `rounded-lg`
- examples:
- `components/ui/dialog.tsx`
- `components/dashboard/dashboard-layout.tsx`
- `components/root/portfolio-project-form.tsx`
- `components/admin/portfolio-project-form.tsx`
## 6. Repeated spacing patterns found in code
@@ -321,14 +321,14 @@ Very common combinations:
- `border border-input bg-background`
- `components/ui/input.tsx`
- `components/ui/select.tsx`
- `components/root/portfolio-categories-manager.tsx`
- `components/root/portfolio-projects-overview.tsx`
- `components/admin/portfolio-categories-manager.tsx`
- `components/admin/portfolio-projects-overview.tsx`
- `bg-primary text-primary-foreground`
- active nav and badges/buttons:
- `components/ui/button.tsx`
- `components/ui/badge.tsx`
- `components/dashboard/sidebar.tsx`
- `components/root/portfolio-subnav.tsx`
- `components/admin/portfolio-subnav.tsx`
- `app/[locale]/(site)/portfolio/page.tsx`
- `text-muted-foreground`
- repeated throughout forms, cards, table headers, descriptions
@@ -406,7 +406,7 @@ Evidence:
- dependency in `package.json`
- broad usage across app and components
- examples:
- `components/root/root-dashboard-shell.tsx`
- `components/admin/root-dashboard-shell.tsx`
- `components/layout/site-header.tsx`
- `components/theme-toggle.tsx`
- `app/root/page.tsx`
@@ -422,8 +422,8 @@ No second React icon library is present in `package.json`.
- public site routes live under:
- `app/[locale]/(site)/*`
- admin routes live under:
- `app/root/*`
- `src` exists but is empty in the current codebase.
- `app/_admin/*` (canonical source), mirrored to `app/admin-internal/*` (rewrite target) and `app/root/*` (dev alias)
- there is no `src/` directory in the codebase.
### Component folders
@@ -431,7 +431,7 @@ No second React icon library is present in `package.json`.
- primitive and near-primitive reusable controls
- `components/layout`
- site shell, hero, header, footer, container, backdrops
- `components/root`
- `components/admin`
- admin feature components and forms
- `components/dashboard`
- admin navigation and dashboard layout shell
@@ -442,7 +442,7 @@ No second React icon library is present in `package.json`.
- `lib/utils.ts`
- `cn()`
- `lib/root-navigation.ts`
- `lib/admin-navigation.ts`
- navigation config for admin shell
- other `lib/*`
- app settings, metadata, locale, media, portfolio, auth
@@ -457,7 +457,7 @@ Confirmed custom wrappers around local primitive layer:
- wraps `Button`
- `components/layout/locale-toggle.tsx`
- uses `Button` and `DropdownMenu`
- `components/root/form-save-button.tsx`
- `components/admin/form-save-button.tsx`
- uses `Button`
## 11. Violations or inconsistencies found in the current codebase
@@ -498,8 +498,8 @@ Examples:
- `components/ui/dialog.tsx`
- `components/dashboard/dashboard-layout.tsx`
- `components/root/site-settings-form.tsx`
- `components/root/portfolio-project-form.tsx`
- `components/admin/site-settings-form.tsx`
- `components/admin/portfolio-project-form.tsx`
### Some primitives follow the local system, some keep stock shadcn-style values
@@ -529,7 +529,7 @@ So the file defines multiple levels, but two of them are currently identical.
Most interactive primitives use Radix wrappers, but `components/ui/tabs.tsx` is a custom React context implementation instead.
### Tailwind scans `src`, but `src` is empty
### Tailwind scans `src`, but `src` does not exist
`tailwind.config.ts`
includes:
@@ -538,7 +538,7 @@ includes:
Current project state:
- `src` contains no files.
- there is no `src/` directory; this glob matches nothing.
## 12. Codex rules derived from the existing system
+7 -7
View File
@@ -215,11 +215,11 @@ Current feature code now reuses these levels in shared admin surfaces.
Examples:
- `components/root/media-field-picker.tsx`
- `components/root/portfolio-projects-overview.tsx`
- `components/root/portfolio-project-form.tsx`
- `components/root/portfolio-categories-manager.tsx`
- `components/root/site-settings-form.tsx`
- `components/admin/media-field-picker.tsx`
- `components/admin/portfolio-projects-overview.tsx`
- `components/admin/portfolio-project-form.tsx`
- `components/admin/portfolio-categories-manager.tsx`
- `components/admin/site-settings-form.tsx`
## 6. Current input/button/select/dialog/sheet rules in use
@@ -485,7 +485,7 @@ Examples:
- `components/ui/select.tsx`
- `components/layout/site-header.tsx`
- `components/theme-toggle.tsx`
- `components/root/portfolio-project-form.tsx`
- `components/admin/portfolio-project-form.tsx`
No second React icon library is present in:
@@ -508,7 +508,7 @@ Remaining transitional or non-shared drift still present in the codebase:
- `app/[locale]/(site)/portfolio/page.tsx`
- `app/[locale]/(site)/portfolio/[slug]/page.tsx`
- one custom dark preview surface remains in current admin code
- `components/root/site-settings-form.tsx`
- `components/admin/site-settings-form.tsx`
- `bg-slate-950`
- `text-white/55`
- `text-white/60`
+12 -12
View File
@@ -77,11 +77,11 @@ Repeated nested panels and stat blocks in feature code were moved toward shared
Refactored areas include:
- `components/root/media-field-picker.tsx`
- `components/root/portfolio-projects-overview.tsx`
- `components/root/portfolio-project-form.tsx`
- `components/root/portfolio-categories-manager.tsx`
- `components/root/site-settings-form.tsx`
- `components/admin/media-field-picker.tsx`
- `components/admin/portfolio-projects-overview.tsx`
- `components/admin/portfolio-project-form.tsx`
- `components/admin/portfolio-categories-manager.tsx`
- `components/admin/site-settings-form.tsx`
### 5. False abstraction fixed
@@ -113,13 +113,13 @@ Current `AppCard` levels are now meaningfully distinct:
- `components/dashboard/dashboard-layout.tsx`
- `components/layout/floating-preferences.tsx`
- `components/layout/site-header.tsx`
- `components/root/flash-message.tsx`
- `components/root/media-field-picker.tsx`
- `components/root/portfolio-categories-manager.tsx`
- `components/root/portfolio-project-form.tsx`
- `components/root/portfolio-projects-overview.tsx`
- `components/root/portfolio-subnav.tsx`
- `components/root/site-settings-form.tsx`
- `components/admin/flash-message.tsx`
- `components/admin/media-field-picker.tsx`
- `components/admin/portfolio-categories-manager.tsx`
- `components/admin/portfolio-project-form.tsx`
- `components/admin/portfolio-projects-overview.tsx`
- `components/admin/portfolio-subnav.tsx`
- `components/admin/site-settings-form.tsx`
- `components/ui/app-card.tsx`
- `components/ui/dialog.tsx`
- `components/ui/sheet.tsx`
+35
View File
@@ -0,0 +1,35 @@
export type FlashMessages = {
success?: string;
error?: string;
};
/**
* Build a redirect target that carries an inline success/error message via
* query params. Consumed by <AdminFlash /> on the destination page (the
* Post/Redirect/Get pattern). Replaces the removed toast transport.
*/
export function withFlash(pathname: string, flash: FlashMessages): string {
const params = new URLSearchParams();
if (flash.success) {
params.set("success", flash.success);
}
if (flash.error) {
params.set("error", flash.error);
}
const query = params.toString();
return query ? `${pathname}?${query}` : pathname;
}
/** Read the flash messages from resolved searchParams. */
export function readFlash(
searchParams?: { success?: string; error?: string } | null,
): FlashMessages {
return {
success: searchParams?.success,
error: searchParams?.error,
};
}
+1 -18
View File
@@ -26,7 +26,6 @@ type AdminNavigationCopy = {
localizationSettings?: string;
marquee?: string;
smtp?: string;
contactProtection?: string;
};
export type AdminNavItem = {
@@ -41,7 +40,6 @@ export type AdminNavItem = {
export function getAdminNavigation(
copy: AdminNavigationCopy,
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
smtpChild?: "settings" | "contact-protection",
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
siteSettingsChild?: "brand" | "localization",
): AdminNavItem[] {
@@ -101,22 +99,7 @@ export function getAdminNavigation(
label: copy.smtp ?? "SMTP",
href: getAdminAppPath("/smtp"),
icon: Mail,
active: active === "smtp" && !smtpChild,
expanded: active === "smtp",
children: [
{
label: copy.smtp ?? "SMTP",
href: getAdminAppPath("/smtp"),
icon: Mail,
active: smtpChild === "settings" || (!smtpChild && active === "smtp"),
},
{
label: copy.contactProtection ?? "Contact Protection",
href: getAdminAppPath("/smtp/contact-protection"),
icon: ShieldAlert,
active: smtpChild === "contact-protection",
},
],
active: active === "smtp",
},
{
label: copy.portfolio,
-60
View File
@@ -24,16 +24,6 @@ export {
type MailSettings,
type MailSettingsFormValues,
} from "./mail-settings";
export {
CONTACT_PROTECTION_SETTINGS_KEY,
buildDefaultContactProtectionSettings,
parseContactProtectionValue,
toContactProtectionFormValues,
toPublicContactProtectionSettings,
type ContactProtectionSettings,
type ContactProtectionFormValues,
type PublicContactProtectionSettings,
} from "./contact-protection";
export {
MARQUEE_SETTINGS_KEY,
buildDefaultMarqueeSettings,
@@ -67,16 +57,6 @@ import {
type MailSettings,
type MailSettingsFormValues,
} from "./mail-settings";
import {
CONTACT_PROTECTION_SETTINGS_KEY,
buildDefaultContactProtectionSettings,
parseContactProtectionValue,
toContactProtectionFormValues,
toPublicContactProtectionSettings,
type ContactProtectionSettings,
type ContactProtectionFormValues,
type PublicContactProtectionSettings,
} from "./contact-protection";
import {
MARQUEE_SETTINGS_KEY,
buildDefaultMarqueeSettings,
@@ -179,46 +159,6 @@ export async function updateMailSettings(settings: MailSettings): Promise<void>
});
}
export async function getContactProtectionSettings(): Promise<ContactProtectionSettings> {
try {
const config = await prisma.appConfig.findUnique({
where: { key: CONTACT_PROTECTION_SETTINGS_KEY },
select: { value: true },
});
return parseContactProtectionValue(config?.value);
} catch {
return buildDefaultContactProtectionSettings();
}
}
export async function getContactProtectionFormValues(): Promise<ContactProtectionFormValues> {
const settings = await getContactProtectionSettings();
return toContactProtectionFormValues(settings);
}
export async function getPublicContactProtectionSettings(): Promise<PublicContactProtectionSettings> {
const settings = await getContactProtectionSettings();
return toPublicContactProtectionSettings(settings);
}
export async function updateContactProtectionSettings(
settings: ContactProtectionSettings,
): Promise<void> {
await prisma.appConfig.upsert({
where: { key: CONTACT_PROTECTION_SETTINGS_KEY },
update: {
value: JSON.stringify(settings),
},
create: {
key: CONTACT_PROTECTION_SETTINGS_KEY,
value: JSON.stringify(settings),
},
});
}
export async function getMarqueeSettings(): Promise<MarqueeSettings> {
try {
const config = await prisma.appConfig.findUnique({
-113
View File
@@ -1,113 +0,0 @@
import { createHash } from "crypto";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import {
CONTACT_RATE_LIMIT_KEY_PREFIX,
type ContactProtectionSettings,
} from "@/lib/contact-protection";
async function getClientIpFromHeaders() {
const requestHeaders = await headers();
const forwardedFor = requestHeaders.get("x-forwarded-for");
if (forwardedFor) {
return forwardedFor.split(",")[0]?.trim() || "unknown";
}
return requestHeaders.get("x-real-ip")?.trim() || "unknown";
}
function getRateLimitKey(ip: string, windowMinutes: number) {
const windowMs = windowMinutes * 60 * 1000;
const windowStart = Math.floor(Date.now() / windowMs) * windowMs;
const ipHash = createHash("sha256").update(ip).digest("hex");
return `${CONTACT_RATE_LIMIT_KEY_PREFIX}:${ipHash}:${windowStart}`;
}
function parseCount(rawValue: string | null | undefined) {
if (!rawValue) {
return 0;
}
const parsed = Number.parseInt(rawValue, 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
}
export async function enforceContactRateLimit(settings: ContactProtectionSettings) {
if (!settings.rateLimit.enabled) {
return;
}
const ip = await getClientIpFromHeaders();
const key = getRateLimitKey(ip, settings.rateLimit.windowMinutes);
// Clean up stale rate limit entries (older than 2x the window) to prevent table bloat.
const cutoffDate = new Date(Date.now() - settings.rateLimit.windowMinutes * 2 * 60 * 1000);
await prisma.$executeRaw`
DELETE FROM "AppConfig"
WHERE key LIKE ${`${CONTACT_RATE_LIMIT_KEY_PREFIX}:%`}
AND "updatedAt" < ${cutoffDate}
`;
// Atomically insert or increment the counter for this IP + window.
const result = await prisma.$queryRaw<Array<{ count: number }>>`
INSERT INTO "AppConfig" (id, key, value, "createdAt", "updatedAt")
VALUES (gen_random_uuid()::text, ${key}, '1', NOW(), NOW())
ON CONFLICT (key) DO UPDATE
SET value = (CAST("AppConfig".value AS INTEGER) + 1)::text,
"updatedAt" = NOW()
RETURNING CAST(value AS INTEGER) AS count
`;
const count = result[0]?.count ?? 0;
if (count > settings.rateLimit.maxRequests) {
throw new Error("Too many contact requests. Please try again later.");
}
}
export async function verifyTurnstileToken(
settings: ContactProtectionSettings,
token: string,
) {
if (!settings.turnstile.enabled) {
return;
}
if (!settings.turnstile.siteKey || !settings.turnstile.secretKey) {
throw new Error("Turnstile is enabled but not fully configured.");
}
if (!token.trim()) {
throw new Error("Turnstile verification is required.");
}
const body = new URLSearchParams();
body.set("secret", settings.turnstile.secretKey);
body.set("response", token);
body.set("remoteip", await getClientIpFromHeaders());
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
cache: "no-store",
});
if (!response.ok) {
throw new Error("Turnstile verification request failed.");
}
const result = await response.json() as {
success?: boolean;
};
if (!result.success) {
throw new Error("Turnstile verification failed.");
}
}
-132
View File
@@ -1,132 +0,0 @@
export const CONTACT_PROTECTION_SETTINGS_KEY = "contact_protection_settings";
export const CONTACT_RATE_LIMIT_KEY_PREFIX = "contact_rate_limit";
export type ContactProtectionSettings = {
turnstile: {
enabled: boolean;
siteKey: string;
secretKey: string;
};
rateLimit: {
enabled: boolean;
maxRequests: number;
windowMinutes: number;
};
};
export type ContactProtectionFormValues = {
turnstile: {
enabled: boolean;
siteKey: string;
secretKey: string;
hasSecretKey: boolean;
};
rateLimit: {
enabled: boolean;
maxRequests: number;
windowMinutes: number;
};
};
export type PublicContactProtectionSettings = {
turnstile: {
enabled: boolean;
siteKey: string;
};
rateLimit: {
enabled: boolean;
};
};
export function buildDefaultContactProtectionSettings(): ContactProtectionSettings {
return {
turnstile: {
enabled: false,
siteKey: "",
secretKey: "",
},
rateLimit: {
enabled: true,
maxRequests: 5,
windowMinutes: 10,
},
};
}
function parsePositiveInt(value: unknown, fallback: number) {
const parsed =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseInt(value, 10)
: fallback;
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
export function parseContactProtectionValue(
rawValue: string | null | undefined,
): ContactProtectionSettings {
const defaults = buildDefaultContactProtectionSettings();
if (!rawValue) {
return defaults;
}
try {
const parsed = JSON.parse(rawValue) as Record<string, unknown>;
const turnstile = parsed.turnstile && typeof parsed.turnstile === "object"
? (parsed.turnstile as Record<string, unknown>)
: {};
const rateLimit = parsed.rateLimit && typeof parsed.rateLimit === "object"
? (parsed.rateLimit as Record<string, unknown>)
: {};
return {
turnstile: {
enabled: Boolean(turnstile.enabled),
siteKey: typeof turnstile.siteKey === "string" ? turnstile.siteKey.trim() : "",
secretKey: typeof turnstile.secretKey === "string" ? turnstile.secretKey : "",
},
rateLimit: {
enabled: rateLimit.enabled === undefined ? defaults.rateLimit.enabled : Boolean(rateLimit.enabled),
maxRequests: parsePositiveInt(rateLimit.maxRequests, defaults.rateLimit.maxRequests),
windowMinutes: parsePositiveInt(rateLimit.windowMinutes, defaults.rateLimit.windowMinutes),
},
};
} catch {
return defaults;
}
}
export function toContactProtectionFormValues(
settings: ContactProtectionSettings,
): ContactProtectionFormValues {
return {
turnstile: {
enabled: settings.turnstile.enabled,
siteKey: settings.turnstile.siteKey,
secretKey: "",
hasSecretKey: Boolean(settings.turnstile.secretKey),
},
rateLimit: {
enabled: settings.rateLimit.enabled,
maxRequests: settings.rateLimit.maxRequests,
windowMinutes: settings.rateLimit.windowMinutes,
},
};
}
export function toPublicContactProtectionSettings(
settings: ContactProtectionSettings,
): PublicContactProtectionSettings {
return {
turnstile: {
enabled: settings.turnstile.enabled && Boolean(settings.turnstile.siteKey),
siteKey: settings.turnstile.siteKey,
},
rateLimit: {
enabled: settings.rateLimit.enabled,
},
};
}
-67
View File
@@ -1,67 +0,0 @@
"use client";
import {
toast as hotToast,
type ToastOptions,
type ToastPosition,
} from "react-hot-toast";
import { FALLBACK_LOCALE, resolveLocale } from "@/lib/locale";
type ToastVariant = "default" | "success" | "error" | "loading";
type ToastMessage = string;
function getCurrentLocale() {
if (typeof window === "undefined") {
return FALLBACK_LOCALE;
}
const pathname = window.location.pathname;
const maybeLocale = pathname.split("/")[1] || FALLBACK_LOCALE;
return resolveLocale(maybeLocale, FALLBACK_LOCALE);
}
function getToastPosition(isArabic: boolean): ToastPosition {
return isArabic ? "top-right" : "top-left";
}
function getToastOptions(): ToastOptions {
const locale = getCurrentLocale();
const isArabic = locale === "ar";
return {
duration: 1800,
position: getToastPosition(isArabic),
};
}
function showToast(message: ToastMessage, variant: ToastVariant = "default") {
const options = getToastOptions();
if (variant === "success") {
return hotToast.success(message, options);
}
if (variant === "error") {
return hotToast.error(message, options);
}
if (variant === "loading") {
return hotToast.loading(message, options);
}
return hotToast(message, options);
}
export const toast = Object.assign(
(message: ToastMessage) => showToast(message, "default"),
{
success: (message: ToastMessage) => showToast(message, "success"),
error: (message: ToastMessage) => showToast(message, "error"),
loading: (message: ToastMessage) => showToast(message, "loading"),
dismiss: hotToast.dismiss,
remove: hotToast.remove,
},
);
+1 -9
View File
@@ -10,16 +10,8 @@
"openMenu": "فتح القائمة",
"closeMenu": "إغلاق القائمة",
"themeToggle": "تبديل المظهر",
"themeLight": "تم تفعيل الوضع الفاتح",
"themeDark": "تم تفعيل الوضع الداكن",
"soundMute": "كتم الأصوات",
"soundUnmute": "تشغيل الأصوات",
"soundMuted": "تم كتم الصوت",
"soundEnabled": "تم تشغيل الصوت",
"localeChangedDe": "تم تغيير اللغة إلى الألمانية",
"localeChangedEn": "تم تغيير اللغة إلى الإنجليزية",
"localeChangedAr": "تم تغيير اللغة إلى العربية",
"logoTripleClick": "هدي اللعب... اللوغو مو زر طوارئ."
"soundUnmute": "تشغيل الأصوات"
},
"footer": {
"line": {
+1 -9
View File
@@ -10,16 +10,8 @@
"openMenu": "Menü öffnen",
"closeMenu": "Menü schliessen",
"themeToggle": "Theme wechseln",
"themeLight": "Heller Modus aktiviert",
"themeDark": "Dunkler Modus aktiviert",
"soundMute": "Sounds stummschalten",
"soundUnmute": "Sounds aktivieren",
"soundMuted": "Sound stummgeschaltet",
"soundEnabled": "Sound aktiviert",
"localeChangedDe": "Sprache auf Deutsch gewechselt",
"localeChangedEn": "Sprache auf Englisch gewechselt",
"localeChangedAr": "Sprache auf Arabisch gewechselt",
"logoTripleClick": "Ganz ruhig. Das Logo ist kein Panikknopf."
"soundUnmute": "Sounds aktivieren"
},
"footer": {
"line": {
+1 -9
View File
@@ -10,16 +10,8 @@
"openMenu": "Open menu",
"closeMenu": "Close menu",
"themeToggle": "Toggle theme",
"themeLight": "Light mode enabled",
"themeDark": "Dark mode enabled",
"soundMute": "Mute sounds",
"soundUnmute": "Unmute sounds",
"soundMuted": "Sound muted",
"soundEnabled": "Sound enabled",
"localeChangedDe": "Language changed to German",
"localeChangedEn": "Language changed to English",
"localeChangedAr": "Language changed to Arabic",
"logoTripleClick": "Easy there. The logo is not a panic button."
"soundUnmute": "Unmute sounds"
},
"footer": {
"line": {
+1 -27
View File
@@ -30,7 +30,6 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"react-hot-toast": "^2.6.0",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
},
@@ -5459,6 +5458,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -6971,15 +6971,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/goober": {
"version": "2.1.18",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -9228,23 +9219,6 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-hot-toast": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
-1
View File
@@ -39,7 +39,6 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.71.2",
"react-hot-toast": "^2.6.0",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
},
-39
View File
@@ -1,39 +0,0 @@
# Downloads Feature Spec
## Current Implementation
- Portfolio assets support
`DOCUMENT`
files
- Portfolio detail pages expose document links with an
`Open document`
action
- Media library can store document assets
## Gaps
- No standalone downloads index exists
- No download access rules exist
- No download analytics or gating exists
- No dedicated
`Download`
model exists
## Proposed Scope
This is a proposed feature, not an implemented one.
- Central downloads listing
- Optional download categories
- Optional gated download access
- Download tracking
## Recommended First Step
- Decide whether downloads remain a portfolio asset type or become their own domain
-29
View File
@@ -1,29 +0,0 @@
# Orders Feature Spec
## Current Implementation
- No
`Order`
model exists
- No checkout flow exists
- No payment integration exists
- No admin order management exists
## Proposed Scope
This is a proposed feature, not an implemented one.
- Order creation from products or inquiry conversions
- Order status lifecycle
- Admin order review and fulfillment tracking
- Email notifications for order events
## Recommended First Step
- Clarify whether
`orders`
means ecommerce checkout, service bookings, or manual sales records
-40
View File
@@ -1,40 +0,0 @@
# Products Feature Spec
## Current Implementation
- No
`Product`
model exists
- No public
`/products`
route exists
- No admin CRUD exists for products
- The site header contains a disabled
`Products`
navigation item marked as
`Soon`
## Proposed Scope
This is a proposed feature, not an implemented one.
- Public product listing page
- Product detail page
- Product media and downloadable assets
- Admin product CRUD
- Optional relation from products to portfolio case studies
## Recommended First Step
- Define a dedicated
`Product`
data model before any UI work
-32
View File
@@ -1,32 +0,0 @@
# Project Inquiry Feature Spec
## Current Implementation
- No separate
`ProjectInquiry`
model exists
- No dedicated project inquiry route exists
- Current CTAs route users to the generic contact form
## Proposed Scope
This is a proposed feature, not an implemented one.
- Inquiry form specifically for new project leads
- Optional prefilled source context from portfolio or homepage CTA
- Inquiry status tracking in admin
- Possible conversion into
`Order`
or CRM lead later
## Recommended First Step
- Decide whether project inquiry should stay a specialized
`contact`
variant or become a persisted lead-management feature
-65
View File
@@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildDefaultContactProtectionSettings,
parseContactProtectionValue,
toContactProtectionFormValues,
} from "../lib/contact-protection";
describe("contact protection helpers", () => {
it("builds safe defaults", () => {
expect(buildDefaultContactProtectionSettings()).toEqual({
turnstile: {
enabled: false,
siteKey: "",
secretKey: "",
},
rateLimit: {
enabled: true,
maxRequests: 5,
windowMinutes: 10,
},
});
});
it("parses stored settings and trims keys", () => {
const settings = parseContactProtectionValue(
JSON.stringify({
turnstile: {
enabled: true,
siteKey: " 0x4AAAAA ",
secretKey: "secret",
},
rateLimit: {
enabled: true,
maxRequests: "8",
windowMinutes: "15",
},
}),
);
expect(settings.turnstile.enabled).toBe(true);
expect(settings.turnstile.siteKey).toBe("0x4AAAAA");
expect(settings.turnstile.secretKey).toBe("secret");
expect(settings.rateLimit.maxRequests).toBe(8);
expect(settings.rateLimit.windowMinutes).toBe(15);
});
it("hides the stored secret in form values", () => {
const values = toContactProtectionFormValues({
turnstile: {
enabled: true,
siteKey: "site-key",
secretKey: "secret-key",
},
rateLimit: {
enabled: true,
maxRequests: 5,
windowMinutes: 10,
},
});
expect(values.turnstile.secretKey).toBe("");
expect(values.turnstile.hasSecretKey).toBe(true);
});
});