refactor: drop toast + over-engineered extras, add inline admin feedback
CI / quality (push) Waiting to run
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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." }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 ?? {}),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (P1–P8):** أيها توافق على إزالته الآن؟ اقتراحي أن نبدأ بالآمن (P5, P3, P8-self-fetch) ونؤجل الأعلى خطراً (P6 مكتبة الميديا، P1 بنية الأدمن) لجلسة منفصلة.
|
||||
3. **تصحيح الوثائق القديمة** (`frontend-system-*.md`): موافقة على تحديث المسارات؟
|
||||
@@ -92,7 +92,6 @@ navigation item marked as
|
||||
- Site Settings
|
||||
- Marquee Settings
|
||||
- SMTP Settings
|
||||
- Contact Protection
|
||||
- Portfolio management
|
||||
|
||||
### Data model
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -10,16 +10,8 @@
|
||||
"openMenu": "فتح القائمة",
|
||||
"closeMenu": "إغلاق القائمة",
|
||||
"themeToggle": "تبديل المظهر",
|
||||
"themeLight": "تم تفعيل الوضع الفاتح",
|
||||
"themeDark": "تم تفعيل الوضع الداكن",
|
||||
"soundMute": "كتم الأصوات",
|
||||
"soundUnmute": "تشغيل الأصوات",
|
||||
"soundMuted": "تم كتم الصوت",
|
||||
"soundEnabled": "تم تشغيل الصوت",
|
||||
"localeChangedDe": "تم تغيير اللغة إلى الألمانية",
|
||||
"localeChangedEn": "تم تغيير اللغة إلى الإنجليزية",
|
||||
"localeChangedAr": "تم تغيير اللغة إلى العربية",
|
||||
"logoTripleClick": "هدي اللعب... اللوغو مو زر طوارئ."
|
||||
"soundUnmute": "تشغيل الأصوات"
|
||||
},
|
||||
"footer": {
|
||||
"line": {
|
||||
|
||||
+1
-9
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+1
-27
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user