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.
|
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
|
### 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` |
|
| AppConfig aggregate | `lib/app-config.ts` |
|
||||||
| Portfolio queries | `lib/portfolio.ts` |
|
| Portfolio queries | `lib/portfolio.ts` |
|
||||||
| Media handling | `lib/media.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
|
### 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 { isRedirectError } from "next/dist/client/components/redirect-error";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { enforceContactRateLimit, verifyTurnstileToken } from "@/lib/contact-guard";
|
import { getSiteSettings } from "@/lib/app-config";
|
||||||
import { getContactProtectionSettings, getSiteSettings } from "@/lib/app-config";
|
|
||||||
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
import { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
import { sendContactMessage } from "@/lib/mail";
|
import { sendContactMessage } from "@/lib/mail";
|
||||||
|
|
||||||
@@ -16,55 +15,32 @@ const contactFormSchema = z.object({
|
|||||||
phone: z.string().trim().max(40).optional(),
|
phone: z.string().trim().max(40).optional(),
|
||||||
company: z.string().trim().max(120).optional(),
|
company: z.string().trim().max(120).optional(),
|
||||||
message: z.string().trim().min(10).max(5000),
|
message: z.string().trim().min(10).max(5000),
|
||||||
turnstileToken: z.string().trim().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const contactErrorMessages = {
|
const contactErrorMessages = {
|
||||||
ar: {
|
ar: "تعذّر إرسال الرسالة. تأكد من تعبئة الحقول بشكل صحيح وحاول مجدداً.",
|
||||||
invalid: "يرجى تعبئة كل الحقول بشكل صحيح.",
|
en: "Your message could not be sent. Please check the fields and try again.",
|
||||||
failed: "تعذر إرسال الرسالة حالياً.",
|
de: "Nachricht konnte nicht gesendet werden. Bitte Eingaben pruefen und erneut versuchen.",
|
||||||
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.",
|
|
||||||
},
|
|
||||||
} as const;
|
} 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) {
|
function getStringValue(formData: FormData, key: string) {
|
||||||
const value = formData.get(key);
|
const value = formData.get(key);
|
||||||
return typeof value === "string" ? value : "";
|
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) {
|
export async function submitContactFormAction(formData: FormData) {
|
||||||
const siteSettings = await getSiteSettings();
|
const siteSettings = await getSiteSettings();
|
||||||
const locale = resolveLocale(String(formData.get("locale") ?? ""), siteSettings.defaultLocale);
|
const locale = resolveLocale(String(formData.get("locale") ?? ""), siteSettings.defaultLocale);
|
||||||
const contactPath = getLocalizedPath(locale, "/contact", siteSettings.defaultLocale);
|
const contactPath = getLocalizedPath(locale, "/contact", siteSettings.defaultLocale);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const protectionSettings = await getContactProtectionSettings();
|
|
||||||
const values = contactFormSchema.parse({
|
const values = contactFormSchema.parse({
|
||||||
locale,
|
locale,
|
||||||
name: getStringValue(formData, "name"),
|
name: getStringValue(formData, "name"),
|
||||||
@@ -72,12 +48,8 @@ export async function submitContactFormAction(formData: FormData) {
|
|||||||
phone: getStringValue(formData, "phone"),
|
phone: getStringValue(formData, "phone"),
|
||||||
company: getStringValue(formData, "company"),
|
company: getStringValue(formData, "company"),
|
||||||
message: getStringValue(formData, "message"),
|
message: getStringValue(formData, "message"),
|
||||||
turnstileToken: getStringValue(formData, "turnstileToken"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await verifyTurnstileToken(protectionSettings, values.turnstileToken ?? "");
|
|
||||||
await enforceContactRateLimit(protectionSettings);
|
|
||||||
|
|
||||||
await sendContactMessage({
|
await sendContactMessage({
|
||||||
locale,
|
locale,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
@@ -87,36 +59,13 @@ export async function submitContactFormAction(formData: FormData) {
|
|||||||
message: values.message,
|
message: values.message,
|
||||||
});
|
});
|
||||||
|
|
||||||
redirect(
|
redirect(getLocalizedPath(locale, "/success", siteSettings.defaultLocale));
|
||||||
withMessage(
|
|
||||||
getLocalizedPath(locale, "/success", siteSettings.defaultLocale),
|
|
||||||
"success",
|
|
||||||
contactSuccessMessages[locale],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirectError(error)) {
|
if (isRedirectError(error)) {
|
||||||
throw 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);
|
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 { MotionFade } from "@/components/motion-fade";
|
||||||
import { ContactForm } from "@/components/site/contact-form";
|
import { ContactForm } from "@/components/site/contact-form";
|
||||||
import { buildLocalizedMetadata } from "@/lib/metadata";
|
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 { getLocalizedPath, resolveLocale } from "@/lib/locale";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -18,6 +18,9 @@ type ContactPageProps = {
|
|||||||
params: Promise<{
|
params: Promise<{
|
||||||
locale: string;
|
locale: string;
|
||||||
}>;
|
}>;
|
||||||
|
searchParams?: Promise<{
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function generateMetadata({ params }: ContactPageProps): Promise<Metadata> {
|
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;
|
await params;
|
||||||
const siteSettings = await getSiteSettings();
|
const siteSettings = await getSiteSettings();
|
||||||
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
const localeKey = resolveLocale(await getLocale().catch(() => siteSettings.defaultLocale), siteSettings.defaultLocale);
|
||||||
const [t, protection] = await Promise.all([
|
const t = await getTranslations({ locale: localeKey, namespace: "contactPage" });
|
||||||
getTranslations({ locale: localeKey, namespace: "contactPage" }),
|
const contactError = (await searchParams)?.error;
|
||||||
getPublicContactProtectionSettings(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -52,6 +53,17 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
description={t("intro")}
|
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">
|
<Container className="grid gap-6 pb-12 lg:grid-cols-2 lg:pb-16">
|
||||||
<MotionFade>
|
<MotionFade>
|
||||||
<AppCard level={3}>
|
<AppCard level={3}>
|
||||||
@@ -85,7 +97,6 @@ export default async function ContactPage({ params }: ContactPageProps) {
|
|||||||
action={submitContactFormAction}
|
action={submitContactFormAction}
|
||||||
locale={localeKey}
|
locale={localeKey}
|
||||||
previewHref={getLocalizedPath(localeKey, "/success", siteSettings.defaultLocale)}
|
previewHref={getLocalizedPath(localeKey, "/success", siteSettings.defaultLocale)}
|
||||||
protection={protection}
|
|
||||||
copy={{
|
copy={{
|
||||||
name: t("name"),
|
name: t("name"),
|
||||||
email: t("email"),
|
email: t("email"),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
import { getMaintenanceMode } from "@/lib/app-config";
|
import { getMaintenanceMode } from "@/lib/app-config";
|
||||||
@@ -28,7 +29,13 @@ const copy = {
|
|||||||
backToSite: "Zur Website",
|
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();
|
const authenticated = await isAdminAuthenticated();
|
||||||
|
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
@@ -47,6 +54,7 @@ export default async function AdminMaintenancePage() {
|
|||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="maintenance"
|
active="maintenance"
|
||||||
|
flash={flash}
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { isRedirectError } from "next/dist/client/components/redirect-error";
|
|||||||
|
|
||||||
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import { withFlash } from "@/lib/admin-feedback";
|
||||||
import { getSiteSettings, updateMarqueeSettings } from "@/lib/app-config";
|
import { getSiteSettings, updateMarqueeSettings } from "@/lib/app-config";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import { getLocalizedPath } from "@/lib/locale";
|
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() {
|
async function revalidateMarqueePages() {
|
||||||
revalidatePath(toInternalAdminPath("/"));
|
revalidatePath(toInternalAdminPath("/"));
|
||||||
@@ -73,13 +68,13 @@ export async function saveMarqueeSettingsAction(formData: FormData) {
|
|||||||
await updateMarqueeSettings(settings);
|
await updateMarqueeSettings(settings);
|
||||||
await revalidateMarqueePages();
|
await revalidateMarqueePages();
|
||||||
|
|
||||||
redirect(withMessage(getAdminAppPath("/marquee"), "success", "Marquee gespeichert."));
|
redirect(withFlash(getAdminAppPath("/marquee"), { success: "Marquee gespeichert." }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirectError(error)) {
|
if (isRedirectError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = error instanceof Error ? error.message : "Marquee konnte nicht gespeichert werden.";
|
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 { MarqueeSettingsForm } from "@/components/admin/marquee-settings-form";
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
@@ -26,7 +27,13 @@ const copy = {
|
|||||||
backToSite: "Zur Website",
|
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())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
redirect(getAdminAppPath("/"));
|
redirect(getAdminAppPath("/"));
|
||||||
}
|
}
|
||||||
@@ -44,6 +51,7 @@ export default async function AdminMarqueePage() {
|
|||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="marquee"
|
active="marquee"
|
||||||
|
flash={flash}
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { isRedirectError } from "next/dist/client/components/redirect-error";
|
|||||||
|
|
||||||
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import { withFlash } from "@/lib/admin-feedback";
|
||||||
import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media";
|
import { countMediaUsageReferences, getMediaAssetById } from "@/lib/media";
|
||||||
import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service";
|
import { createStandaloneMediaAsset, deleteMediaAssetAndFile } from "@/lib/media-service";
|
||||||
import { isManagedMediaFilePath } from "@/lib/media-storage";
|
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() {
|
function revalidateMediaPages() {
|
||||||
revalidatePath(toInternalAdminPath("/"));
|
revalidatePath(toInternalAdminPath("/"));
|
||||||
@@ -47,14 +42,14 @@ export async function createMediaAssetAction(formData: FormData) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
revalidateMediaPages();
|
revalidateMediaPages();
|
||||||
redirect(withMessage(getAdminAppPath("/media"), "success", "Datei gespeichert."));
|
redirect(withFlash(getAdminAppPath("/media"), { success: "Datei gespeichert." }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirectError(error)) {
|
if (isRedirectError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = error instanceof Error ? error.message : "Datei konnte nicht gespeichert werden.";
|
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);
|
const asset = await getMediaAssetById(assetId);
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
redirect(withMessage(getAdminAppPath("/media"), "error", "Datei nicht gefunden."));
|
redirect(withFlash(getAdminAppPath("/media"), { error: "Datei nicht gefunden." }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const usageCount = await countMediaUsageReferences(asset.id);
|
const usageCount = await countMediaUsageReferences(asset.id);
|
||||||
|
|
||||||
if (usageCount > 0) {
|
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({
|
await prisma.mediaAsset.delete({
|
||||||
@@ -90,13 +85,13 @@ export async function deleteMediaAssetAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
revalidateMediaPages();
|
revalidateMediaPages();
|
||||||
redirect(withMessage(getAdminAppPath("/media"), "success", "Datei geloescht."));
|
redirect(withFlash(getAdminAppPath("/media"), { success: "Datei geloescht." }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirectError(error)) {
|
if (isRedirectError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = error instanceof Error ? error.message : "Datei konnte nicht geloescht werden.";
|
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 { MediaLibraryManager } from "@/components/admin/media-library-manager";
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
import { getAdminMediaAssets } from "@/lib/media";
|
import { getAdminMediaAssets } from "@/lib/media";
|
||||||
@@ -21,7 +22,13 @@ const copy = {
|
|||||||
backToSite: "Zur Website",
|
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())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
redirect(getAdminAppPath("/"));
|
redirect(getAdminAppPath("/"));
|
||||||
}
|
}
|
||||||
@@ -39,6 +46,7 @@ export default async function AdminMediaPage() {
|
|||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="media"
|
active="media"
|
||||||
|
flash={flash}
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
setAdminSessionCookie,
|
setAdminSessionCookie,
|
||||||
} from "@/lib/admin-auth";
|
} from "@/lib/admin-auth";
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { getMaintenanceMode } from "@/lib/app-config";
|
import { getMaintenanceMode } from "@/lib/app-config";
|
||||||
import { getAdminMediaAssets } from "@/lib/media";
|
import { getAdminMediaAssets } from "@/lib/media";
|
||||||
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
|
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
|
||||||
@@ -28,6 +29,7 @@ import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/po
|
|||||||
type AdminPageProps = {
|
type AdminPageProps = {
|
||||||
searchParams?: Promise<{
|
searchParams?: Promise<{
|
||||||
error?: string;
|
error?: string;
|
||||||
|
success?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,6 +65,7 @@ const copy = {
|
|||||||
|
|
||||||
export default async function AdminPage({ searchParams }: AdminPageProps) {
|
export default async function AdminPage({ searchParams }: AdminPageProps) {
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const flash = readFlash(resolvedSearchParams);
|
||||||
const authConfigured = isAdminAuthConfigured();
|
const authConfigured = isAdminAuthConfigured();
|
||||||
const basicConfigured = Boolean(
|
const basicConfigured = Boolean(
|
||||||
process.env.ADMIN_BASIC_AUTH_USER && process.env.ADMIN_BASIC_AUTH_PASS,
|
process.env.ADMIN_BASIC_AUTH_USER && process.env.ADMIN_BASIC_AUTH_PASS,
|
||||||
@@ -186,6 +189,7 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
|
|||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="overview"
|
active="overview"
|
||||||
|
flash={flash}
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ZodError } from "zod";
|
|||||||
|
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
||||||
|
import { withFlash } from "@/lib/admin-feedback";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { deleteEntityMediaUsages, replaceEntityMediaUsages } from "@/lib/media";
|
import { deleteEntityMediaUsages, replaceEntityMediaUsages } from "@/lib/media";
|
||||||
import { resolveMediaSelection } from "@/lib/media-service";
|
import { resolveMediaSelection } from "@/lib/media-service";
|
||||||
@@ -35,12 +36,6 @@ function getRedirectPath(formData: FormData, fallbackPath: string) {
|
|||||||
return String(formData.get("redirectPath") ?? fallbackPath);
|
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) {
|
function normalizeCheckboxValue(formData: FormData, key: string) {
|
||||||
return isCheckedFormValue(formData.get(key));
|
return isCheckedFormValue(formData.get(key));
|
||||||
@@ -139,7 +134,7 @@ export async function upsertCategoryAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await revalidatePortfolioPages();
|
await revalidatePortfolioPages();
|
||||||
redirect(withMessage(redirectPath, "success", "Kategorie gespeichert."));
|
redirect(withFlash(redirectPath, { success: "Kategorie gespeichert." }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirectError(error)) {
|
if (isRedirectError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -152,7 +147,7 @@ export async function upsertCategoryAction(formData: FormData) {
|
|||||||
? "Kategorie Slug muss eindeutig sein."
|
? "Kategorie Slug muss eindeutig sein."
|
||||||
: "Kategorie konnte nicht gespeichert werden.";
|
: "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) {
|
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({
|
await prisma.category.delete({
|
||||||
@@ -180,13 +175,13 @@ export async function deleteCategoryAction(formData: FormData) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await revalidatePortfolioPages();
|
await revalidatePortfolioPages();
|
||||||
redirect(withMessage(redirectPath, "success", "Kategorie geloescht."));
|
redirect(withFlash(redirectPath, { success: "Kategorie geloescht." }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirectError(error)) {
|
if (isRedirectError(error)) {
|
||||||
throw 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(
|
redirect(
|
||||||
withMessage(getAdminAppPath(`/portfolio/projects/${projectResult.project.id}`), "success", "Projekt gespeichert."),
|
withFlash(getAdminAppPath(`/portfolio/projects/${projectResult.project.id}`), {
|
||||||
|
success: "Projekt gespeichert.",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirectError(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) {
|
if (!project) {
|
||||||
redirect(withMessage(getAdminAppPath("/portfolio"), "error", "Projekt nicht gefunden."));
|
redirect(withFlash(getAdminAppPath("/portfolio"), { error: "Projekt nicht gefunden." }));
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.portfolioProject.delete({
|
await prisma.portfolioProject.delete({
|
||||||
@@ -600,12 +597,12 @@ export async function deleteProjectAction(formData: FormData) {
|
|||||||
revalidatePath(getLocalizedPath(locale, `/portfolio/${project.slug}`, siteSettings.defaultLocale));
|
revalidatePath(getLocalizedPath(locale, `/portfolio/${project.slug}`, siteSettings.defaultLocale));
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect(withMessage(getAdminAppPath("/portfolio"), "success", "Project deleted."));
|
redirect(withFlash(getAdminAppPath("/portfolio"), { success: "Projekt geloescht." }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirectError(error)) {
|
if (isRedirectError(error)) {
|
||||||
throw 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 { PortfolioCategoriesManager } from "@/components/admin/portfolio-categories-manager";
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
import { getAdminPortfolioCategories } from "@/lib/portfolio";
|
import { getAdminPortfolioCategories } from "@/lib/portfolio";
|
||||||
@@ -23,7 +24,13 @@ const copy = {
|
|||||||
backToSite: "Zur Website",
|
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())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
redirect(getAdminAppPath("/"));
|
redirect(getAdminAppPath("/"));
|
||||||
}
|
}
|
||||||
@@ -43,6 +50,7 @@ export default async function AdminPortfolioCategoriesPage() {
|
|||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="portfolio"
|
active="portfolio"
|
||||||
|
flash={flash}
|
||||||
portfolioChild="categories"
|
portfolioChild="categories"
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { PortfolioProjectsOverview } from "@/components/admin/portfolio-projects-overview";
|
import { PortfolioProjectsOverview } from "@/components/admin/portfolio-projects-overview";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
@@ -32,6 +33,7 @@ type AdminPortfolioPageProps = {
|
|||||||
|
|
||||||
export default async function AdminPortfolioPage({ searchParams }: AdminPortfolioPageProps) {
|
export default async function AdminPortfolioPage({ searchParams }: AdminPortfolioPageProps) {
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const flash = readFlash(resolvedSearchParams);
|
||||||
|
|
||||||
if (!(await isAdminAuthenticated())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
redirect(getAdminAppPath("/"));
|
redirect(getAdminAppPath("/"));
|
||||||
@@ -62,6 +64,7 @@ export default async function AdminPortfolioPage({ searchParams }: AdminPortfoli
|
|||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="portfolio"
|
active="portfolio"
|
||||||
|
flash={flash}
|
||||||
portfolioChild="overview"
|
portfolioChild="overview"
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { PortfolioProjectForm } from "@/components/admin/portfolio-project-form";
|
import { PortfolioProjectForm } from "@/components/admin/portfolio-project-form";
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { AppCard } from "@/components/ui/app-card";
|
import { AppCard } from "@/components/ui/app-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
@@ -48,11 +49,15 @@ type AdminPortfolioProjectPageProps = {
|
|||||||
params: Promise<{
|
params: Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
}>;
|
}>;
|
||||||
|
searchParams?: Promise<{ success?: string; error?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function AdminPortfolioProjectPage({
|
export default async function AdminPortfolioProjectPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: AdminPortfolioProjectPageProps) {
|
}: AdminPortfolioProjectPageProps) {
|
||||||
|
const flash = readFlash(await searchParams);
|
||||||
|
|
||||||
if (!(await isAdminAuthenticated())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
redirect(getAdminAppPath("/"));
|
redirect(getAdminAppPath("/"));
|
||||||
}
|
}
|
||||||
@@ -73,13 +78,14 @@ export default async function AdminPortfolioProjectPage({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
redirect(`${getAdminAppPath("/portfolio")}?error=Project+not+found.`);
|
redirect(getAdminAppPath("/portfolio"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="portfolio"
|
active="portfolio"
|
||||||
|
flash={flash}
|
||||||
portfolioChild="projects"
|
portfolioChild="projects"
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { PortfolioProjectForm } from "@/components/admin/portfolio-project-form";
|
import { PortfolioProjectForm } from "@/components/admin/portfolio-project-form";
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
import { getMediaOptions } from "@/lib/media";
|
import { getMediaOptions } from "@/lib/media";
|
||||||
@@ -25,7 +26,13 @@ const copy = {
|
|||||||
backToSite: "Zur Website",
|
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())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
redirect(getAdminAppPath("/"));
|
redirect(getAdminAppPath("/"));
|
||||||
}
|
}
|
||||||
@@ -46,6 +53,7 @@ export default async function AdminNewPortfolioProjectPage() {
|
|||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="portfolio"
|
active="portfolio"
|
||||||
|
flash={flash}
|
||||||
portfolioChild="new-project"
|
portfolioChild="new-project"
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
import { PortfolioProjectsOverview } from "@/components/admin/portfolio-projects-overview";
|
import { PortfolioProjectsOverview } from "@/components/admin/portfolio-projects-overview";
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
|
import { getAdminPortfolioCategories, getAdminPortfolioProjects } from "@/lib/portfolio";
|
||||||
@@ -25,6 +26,8 @@ type AdminPortfolioProjectsPageProps = {
|
|||||||
searchParams?: Promise<{
|
searchParams?: Promise<{
|
||||||
category?: string;
|
category?: string;
|
||||||
status?: "all" | "draft" | "published";
|
status?: "all" | "draft" | "published";
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,6 +35,7 @@ export default async function AdminPortfolioProjectsPage({
|
|||||||
searchParams,
|
searchParams,
|
||||||
}: AdminPortfolioProjectsPageProps) {
|
}: AdminPortfolioProjectsPageProps) {
|
||||||
const resolvedSearchParams = await searchParams;
|
const resolvedSearchParams = await searchParams;
|
||||||
|
const flash = readFlash(resolvedSearchParams);
|
||||||
|
|
||||||
if (!(await isAdminAuthenticated())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
redirect(getAdminAppPath("/"));
|
redirect(getAdminAppPath("/"));
|
||||||
@@ -62,6 +66,7 @@ export default async function AdminPortfolioProjectsPage({
|
|||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="portfolio"
|
active="portfolio"
|
||||||
|
flash={flash}
|
||||||
portfolioChild="projects"
|
portfolioChild="projects"
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
updateSiteSettings,
|
updateSiteSettings,
|
||||||
} from "@/lib/app-config";
|
} from "@/lib/app-config";
|
||||||
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
||||||
|
import { withFlash } from "@/lib/admin-feedback";
|
||||||
import {
|
import {
|
||||||
PAGE_TITLE_TOKEN,
|
PAGE_TITLE_TOKEN,
|
||||||
normalizeSiteDefaultLocale,
|
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) {
|
function parseJsonObject(rawValue: FormDataEntryValue | null, key: string) {
|
||||||
if (typeof rawValue !== "string" || rawValue.trim() === "") {
|
if (typeof rawValue !== "string" || rawValue.trim() === "") {
|
||||||
@@ -260,7 +255,7 @@ export async function saveSiteBrandSettingsAction(formData: FormData) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await revalidateSiteSettingsPages(parsedSettings.defaultLocale);
|
await revalidateSiteSettingsPages(parsedSettings.defaultLocale);
|
||||||
redirect(withMessage(getAdminAppPath("/site-settings/brand"), "success", "Einstellungen gespeichert."));
|
redirect(withFlash(getAdminAppPath("/site-settings/brand"), { success: "Einstellungen gespeichert." }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirectError(error)) {
|
if (isRedirectError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -273,7 +268,7 @@ export async function saveSiteBrandSettingsAction(formData: FormData) {
|
|||||||
? error.message
|
? error.message
|
||||||
: "Einstellungen konnten nicht gespeichert werden.";
|
: "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 updateSiteSettings(parsedSettings);
|
||||||
await revalidateSiteSettingsPages(parsedSettings.defaultLocale);
|
await revalidateSiteSettingsPages(parsedSettings.defaultLocale);
|
||||||
redirect(withMessage(getAdminAppPath("/site-settings/localization"), "success", "Einstellungen gespeichert."));
|
redirect(withFlash(getAdminAppPath("/site-settings/localization"), { success: "Einstellungen gespeichert." }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirectError(error)) {
|
if (isRedirectError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -334,6 +329,6 @@ export async function saveSiteLocalizationSettingsAction(formData: FormData) {
|
|||||||
? error.message
|
? error.message
|
||||||
: "Einstellungen konnten nicht gespeichert werden.";
|
: "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 { MotionFade } from "@/components/motion-fade";
|
||||||
import { SiteSettingsForm } from "@/components/admin/site-settings-form";
|
import { SiteSettingsForm } from "@/components/admin/site-settings-form";
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,13 @@ const copy = {
|
|||||||
backToSite: "Zur Website",
|
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())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
redirect(getAdminAppPath("/"));
|
redirect(getAdminAppPath("/"));
|
||||||
}
|
}
|
||||||
@@ -54,6 +61,7 @@ export default async function AdminSiteBrandSettingsPage() {
|
|||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="site-settings"
|
active="site-settings"
|
||||||
|
flash={flash}
|
||||||
siteSettingsChild="brand"
|
siteSettingsChild="brand"
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { SiteSettingsForm } from "@/components/admin/site-settings-form";
|
import { SiteSettingsForm } from "@/components/admin/site-settings-form";
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +31,13 @@ const copy = {
|
|||||||
backToSite: "Zur Website",
|
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())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
redirect(getAdminAppPath("/"));
|
redirect(getAdminAppPath("/"));
|
||||||
}
|
}
|
||||||
@@ -48,6 +55,7 @@ export default async function AdminSiteLocalizationSettingsPage() {
|
|||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="site-settings"
|
active="site-settings"
|
||||||
|
flash={flash}
|
||||||
siteSettingsChild="localization"
|
siteSettingsChild="localization"
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { isRedirectError } from "next/dist/client/components/redirect-error";
|
|||||||
|
|
||||||
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
import { getAdminAppPath, toInternalAdminPath } from "@/lib/admin-routing";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
|
import { withFlash } from "@/lib/admin-feedback";
|
||||||
import { isCheckedFormValue } from "@/lib/form-data";
|
import { isCheckedFormValue } from "@/lib/form-data";
|
||||||
import {
|
import {
|
||||||
getMailSettings,
|
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) {
|
function parsePort(value: string) {
|
||||||
const port = Number.parseInt(value, 10);
|
const port = Number.parseInt(value, 10);
|
||||||
@@ -79,7 +74,7 @@ export async function saveMailSettingsAction(formData: FormData) {
|
|||||||
|
|
||||||
await updateMailSettings(nextMailSettings);
|
await updateMailSettings(nextMailSettings);
|
||||||
revalidatePath(toInternalAdminPath("/smtp"));
|
revalidatePath(toInternalAdminPath("/smtp"));
|
||||||
redirect(withMessage(getAdminAppPath("/smtp"), "success", "SMTP Einstellungen gespeichert."));
|
redirect(withFlash(getAdminAppPath("/smtp"), { success: "SMTP Einstellungen gespeichert." }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirectError(error)) {
|
if (isRedirectError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -90,7 +85,7 @@ export async function saveMailSettingsAction(formData: FormData) {
|
|||||||
? error.message
|
? error.message
|
||||||
: "SMTP Einstellungen konnten nicht gespeichert werden.";
|
: "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 {
|
try {
|
||||||
await sendTestEmail();
|
await sendTestEmail();
|
||||||
redirect(withMessage(getAdminAppPath("/smtp"), "success", "Test-E-Mail gesendet."));
|
redirect(withFlash(getAdminAppPath("/smtp"), { success: "Test-E-Mail gesendet." }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isRedirectError(error)) {
|
if (isRedirectError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -110,6 +105,6 @@ export async function sendTestEmailAction() {
|
|||||||
? error.message
|
? error.message
|
||||||
: "Test-E-Mail konnte nicht gesendet werden.";
|
: "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 { MotionFade } from "@/components/motion-fade";
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { SMTPSettingsForm } from "@/components/admin/smtp-settings-form";
|
import { SMTPSettingsForm } from "@/components/admin/smtp-settings-form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
@@ -21,13 +22,18 @@ const copy = {
|
|||||||
media: "Media",
|
media: "Media",
|
||||||
siteSettings: "Settings",
|
siteSettings: "Settings",
|
||||||
smtp: "SMTP",
|
smtp: "SMTP",
|
||||||
contactProtection: "Contact Protection",
|
|
||||||
portfolio: "Portfolio",
|
portfolio: "Portfolio",
|
||||||
logout: "Ausloggen",
|
logout: "Ausloggen",
|
||||||
backToSite: "Zur Website",
|
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())) {
|
if (!(await isAdminAuthenticated())) {
|
||||||
redirect(getAdminAppPath("/"));
|
redirect(getAdminAppPath("/"));
|
||||||
}
|
}
|
||||||
@@ -45,7 +51,7 @@ export default async function AdminSMTPPage() {
|
|||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="smtp"
|
active="smtp"
|
||||||
smtpChild="settings"
|
flash={flash}
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
headerDescription={copy.subtitle}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
import { AdminDashboardShell } from "@/components/admin/admin-dashboard-shell";
|
||||||
|
import { readFlash } from "@/lib/admin-feedback";
|
||||||
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth";
|
||||||
import { getAdminAppPath } from "@/lib/admin-routing";
|
import { getAdminAppPath } from "@/lib/admin-routing";
|
||||||
import { UiKitShowcase } from "@/components/ui/ui-kit-showcase";
|
import { UiKitShowcase } from "@/components/ui/ui-kit-showcase";
|
||||||
@@ -21,7 +22,13 @@ const copy = {
|
|||||||
backToSite: "Zur Website",
|
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();
|
const authenticated = await isAdminAuthenticated();
|
||||||
|
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
@@ -39,6 +46,7 @@ export default async function AdminUiKitPage() {
|
|||||||
<AdminDashboardShell
|
<AdminDashboardShell
|
||||||
copy={copy}
|
copy={copy}
|
||||||
active="ui-kit"
|
active="ui-kit"
|
||||||
|
flash={flash}
|
||||||
logoutAction={logoutAction}
|
logoutAction={logoutAction}
|
||||||
headerTitle={copy.title}
|
headerTitle={copy.title}
|
||||||
headerDescription={copy.subtitle}
|
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 localFont from "next/font/local";
|
||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { getLocale } from "next-intl/server";
|
import { getLocale } from "next-intl/server";
|
||||||
import { QueryToastBridge } from "@/components/admin/query-toast-bridge";
|
|
||||||
import { SoundProvider } from "@/components/sound-provider";
|
import { SoundProvider } from "@/components/sound-provider";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
|
||||||
import { getSiteSettings } from "@/lib/app-config";
|
import { getSiteSettings } from "@/lib/app-config";
|
||||||
import { buildAppMetadata } from "@/lib/metadata";
|
import { buildAppMetadata } from "@/lib/metadata";
|
||||||
import { getDirection } from "@/lib/locale";
|
import { getDirection } from "@/lib/locale";
|
||||||
@@ -96,8 +94,6 @@ export default async function RootLayout({
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<SoundProvider>
|
<SoundProvider>
|
||||||
{children}
|
{children}
|
||||||
<QueryToastBridge />
|
|
||||||
<Toaster locale={locale} />
|
|
||||||
</SoundProvider>
|
</SoundProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
<Script
|
<Script
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../../../_admin/smtp/contact-protection/page";
|
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { AdminFlash } from "@/components/admin/admin-flash";
|
||||||
import { DashboardLayout } from "@/components/dashboard/dashboard-layout";
|
import { DashboardLayout } from "@/components/dashboard/dashboard-layout";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
import { MotionFade } from "@/components/motion-fade";
|
||||||
import { SidebarMaintenanceControl } from "@/components/admin/sidebar-maintenance-control";
|
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 { buildSiteUrl, getAdminAppPath } from "@/lib/admin-routing";
|
||||||
import { getMaintenanceMode, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
import { getMaintenanceMode, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
||||||
import { getAdminNavigation } from "@/lib/admin-navigation";
|
import { getAdminNavigation } from "@/lib/admin-navigation";
|
||||||
|
import type { FlashMessages } from "@/lib/admin-feedback";
|
||||||
|
|
||||||
import { updateMaintenanceModeAction } from "@/app/_admin/maintenance/actions";
|
import { updateMaintenanceModeAction } from "@/app/_admin/maintenance/actions";
|
||||||
|
|
||||||
@@ -40,7 +42,6 @@ type AdminDashboardCopy = {
|
|||||||
localizationSettings?: string;
|
localizationSettings?: string;
|
||||||
marquee?: string;
|
marquee?: string;
|
||||||
smtp?: string;
|
smtp?: string;
|
||||||
contactProtection?: string;
|
|
||||||
logout: string;
|
logout: string;
|
||||||
backToSite: string;
|
backToSite: string;
|
||||||
};
|
};
|
||||||
@@ -48,9 +49,9 @@ type AdminDashboardCopy = {
|
|||||||
type AdminDashboardShellProps = {
|
type AdminDashboardShellProps = {
|
||||||
copy: AdminDashboardCopy;
|
copy: AdminDashboardCopy;
|
||||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
|
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
|
||||||
smtpChild?: "settings" | "contact-protection";
|
|
||||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
|
||||||
siteSettingsChild?: "brand" | "localization";
|
siteSettingsChild?: "brand" | "localization";
|
||||||
|
flash?: FlashMessages;
|
||||||
logoutAction: () => Promise<void>;
|
logoutAction: () => Promise<void>;
|
||||||
headerTitle: string;
|
headerTitle: string;
|
||||||
headerDescription: string;
|
headerDescription: string;
|
||||||
@@ -63,9 +64,9 @@ type AdminDashboardShellProps = {
|
|||||||
export async function AdminDashboardShell({
|
export async function AdminDashboardShell({
|
||||||
copy,
|
copy,
|
||||||
active,
|
active,
|
||||||
smtpChild,
|
|
||||||
portfolioChild,
|
portfolioChild,
|
||||||
siteSettingsChild,
|
siteSettingsChild,
|
||||||
|
flash,
|
||||||
logoutAction,
|
logoutAction,
|
||||||
headerTitle,
|
headerTitle,
|
||||||
headerDescription,
|
headerDescription,
|
||||||
@@ -78,7 +79,7 @@ export async function AdminDashboardShell({
|
|||||||
getSiteSettingsMediaBindings(),
|
getSiteSettingsMediaBindings(),
|
||||||
getMaintenanceMode(),
|
getMaintenanceMode(),
|
||||||
]);
|
]);
|
||||||
const sidebarItems = getAdminNavigation(copy, active, smtpChild, portfolioChild, siteSettingsChild);
|
const sidebarItems = getAdminNavigation(copy, active, portfolioChild, siteSettingsChild);
|
||||||
const normalizedSidebarItems = sidebarItems.filter(
|
const normalizedSidebarItems = sidebarItems.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.href !== getAdminAppPath("/maintenance") &&
|
item.href !== getAdminAppPath("/maintenance") &&
|
||||||
@@ -155,18 +156,19 @@ export async function AdminDashboardShell({
|
|||||||
<SoundToggle
|
<SoundToggle
|
||||||
ariaLabel="Mute sounds"
|
ariaLabel="Mute sounds"
|
||||||
mutedAriaLabel="Unmute sounds"
|
mutedAriaLabel="Unmute sounds"
|
||||||
mutedToastLabel="Sound muted"
|
|
||||||
unmutedToastLabel="Sound enabled"
|
|
||||||
/>
|
/>
|
||||||
<ThemeToggle
|
<ThemeToggle
|
||||||
ariaLabel="Theme wechseln"
|
ariaLabel="Theme wechseln"
|
||||||
lightToastLabel="Light mode enabled"
|
|
||||||
darkToastLabel="Dark mode enabled"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-6">
|
<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}
|
{toolbar ? <MotionFade delay={0.05}>{toolbar}</MotionFade> : null}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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
|
<SoundToggle
|
||||||
ariaLabel={t("soundMute")}
|
ariaLabel={t("soundMute")}
|
||||||
mutedAriaLabel={t("soundUnmute")}
|
mutedAriaLabel={t("soundUnmute")}
|
||||||
mutedToastLabel={t("soundMuted")}
|
|
||||||
unmutedToastLabel={t("soundEnabled")}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-9 w-9 rounded-pill border border-transparent bg-transparent text-foreground/80 hover:bg-accent hover:text-foreground"
|
className="h-9 w-9 rounded-pill border border-transparent bg-transparent text-foreground/80 hover:bg-accent hover:text-foreground"
|
||||||
/>
|
/>
|
||||||
<ThemeToggle
|
<ThemeToggle
|
||||||
ariaLabel={t("themeToggle")}
|
ariaLabel={t("themeToggle")}
|
||||||
lightToastLabel={t("themeLight")}
|
|
||||||
darkToastLabel={t("themeDark")}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-9 w-9 rounded-pill border border-transparent bg-transparent text-foreground/80 hover:bg-accent hover:text-foreground"
|
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 { AppLocale, getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const PENDING_TOAST_STORAGE_KEY = "mohfarawati-pending-toast";
|
|
||||||
|
|
||||||
type LocaleToggleProps = {
|
type LocaleToggleProps = {
|
||||||
locale: string;
|
locale: string;
|
||||||
defaultLocale: AppLocale;
|
defaultLocale: AppLocale;
|
||||||
@@ -22,12 +20,6 @@ type LocaleToggleProps = {
|
|||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const localeChangedMessages: Record<AppLocale, string> = {
|
|
||||||
de: "Sprache auf Deutsch gewechselt",
|
|
||||||
en: "Language changed to English",
|
|
||||||
ar: "تم تغيير اللغة إلى العربية",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LocaleToggle({
|
export function LocaleToggle({
|
||||||
locale,
|
locale,
|
||||||
defaultLocale,
|
defaultLocale,
|
||||||
@@ -85,22 +77,6 @@ export function LocaleToggle({
|
|||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={getLocalizedPath(targetLocale, currentPath, defaultLocale)}
|
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"
|
className="group flex h-10 w-10 items-center justify-center rounded-full"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { ThemeToggle } from "@/components/theme-toggle";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { buildAdminUrl } from "@/lib/admin-routing";
|
import { buildAdminUrl } from "@/lib/admin-routing";
|
||||||
import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
|
import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
|
||||||
import { toast } from "@/lib/toast";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -211,7 +210,6 @@ export function SiteHeader({
|
|||||||
|
|
||||||
logoClickTimesRef.current = [];
|
logoClickTimesRef.current = [];
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toast(t("logoTripleClick"));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -317,15 +315,11 @@ export function SiteHeader({
|
|||||||
<SoundToggle
|
<SoundToggle
|
||||||
ariaLabel={t("soundMute")}
|
ariaLabel={t("soundMute")}
|
||||||
mutedAriaLabel={t("soundUnmute")}
|
mutedAriaLabel={t("soundUnmute")}
|
||||||
mutedToastLabel={t("soundMuted")}
|
|
||||||
unmutedToastLabel={t("soundEnabled")}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={desktopControlButtonClassName}
|
className={desktopControlButtonClassName}
|
||||||
/>
|
/>
|
||||||
<ThemeToggle
|
<ThemeToggle
|
||||||
ariaLabel={t("themeToggle")}
|
ariaLabel={t("themeToggle")}
|
||||||
lightToastLabel={t("themeLight")}
|
|
||||||
darkToastLabel={t("themeDark")}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={desktopControlButtonClassName}
|
className={desktopControlButtonClassName}
|
||||||
/>
|
/>
|
||||||
@@ -467,15 +461,11 @@ export function SiteHeader({
|
|||||||
<SoundToggle
|
<SoundToggle
|
||||||
ariaLabel={t("soundMute")}
|
ariaLabel={t("soundMute")}
|
||||||
mutedAriaLabel={t("soundUnmute")}
|
mutedAriaLabel={t("soundUnmute")}
|
||||||
mutedToastLabel={t("soundMuted")}
|
|
||||||
unmutedToastLabel={t("soundEnabled")}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={mobileControlButtonClassName}
|
className={mobileControlButtonClassName}
|
||||||
/>
|
/>
|
||||||
<ThemeToggle
|
<ThemeToggle
|
||||||
ariaLabel={t("themeToggle")}
|
ariaLabel={t("themeToggle")}
|
||||||
lightToastLabel={t("themeLight")}
|
|
||||||
darkToastLabel={t("themeDark")}
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={mobileControlButtonClassName}
|
className={mobileControlButtonClassName}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { ContactTurnstile } from "@/components/site/contact-turnstile";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import type { PublicContactProtectionSettings } from "@/lib/contact-protection";
|
|
||||||
|
|
||||||
type ContactFormCopy = {
|
type ContactFormCopy = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -25,7 +23,6 @@ type ContactFormProps = {
|
|||||||
locale: string;
|
locale: string;
|
||||||
previewHref: string;
|
previewHref: string;
|
||||||
copy: ContactFormCopy;
|
copy: ContactFormCopy;
|
||||||
protection: PublicContactProtectionSettings;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ContactForm({
|
export function ContactForm({
|
||||||
@@ -33,7 +30,6 @@ export function ContactForm({
|
|||||||
locale,
|
locale,
|
||||||
previewHref,
|
previewHref,
|
||||||
copy,
|
copy,
|
||||||
protection,
|
|
||||||
}: ContactFormProps) {
|
}: ContactFormProps) {
|
||||||
return (
|
return (
|
||||||
<form action={action} className="grid gap-5">
|
<form action={action} className="grid gap-5">
|
||||||
@@ -68,10 +64,6 @@ export function ContactForm({
|
|||||||
|
|
||||||
<p className="text-sm text-muted-foreground">{copy.note}</p>
|
<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">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button type="submit">{copy.submit}</Button>
|
<Button type="submit">{copy.submit}</Button>
|
||||||
<Button asChild variant="outline">
|
<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 { Volume2, VolumeX } from "lucide-react";
|
||||||
import { useSound } from "@/components/sound-provider";
|
import { useSound } from "@/components/sound-provider";
|
||||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||||
import { toast } from "@/lib/toast";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type SoundToggleProps = {
|
type SoundToggleProps = {
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
mutedAriaLabel?: string;
|
mutedAriaLabel?: string;
|
||||||
mutedToastLabel?: string;
|
|
||||||
unmutedToastLabel?: string;
|
|
||||||
variant?: ButtonProps["variant"];
|
variant?: ButtonProps["variant"];
|
||||||
className?: string;
|
className?: string;
|
||||||
iconClassName?: string;
|
iconClassName?: string;
|
||||||
@@ -19,8 +16,6 @@ type SoundToggleProps = {
|
|||||||
export function SoundToggle({
|
export function SoundToggle({
|
||||||
ariaLabel = "Mute sounds",
|
ariaLabel = "Mute sounds",
|
||||||
mutedAriaLabel = "Unmute sounds",
|
mutedAriaLabel = "Unmute sounds",
|
||||||
mutedToastLabel = "Sound muted",
|
|
||||||
unmutedToastLabel = "Sound enabled",
|
|
||||||
variant = "outline",
|
variant = "outline",
|
||||||
className,
|
className,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
@@ -29,7 +24,6 @@ export function SoundToggle({
|
|||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
toggleMuted();
|
toggleMuted();
|
||||||
toast(isMuted ? unmutedToastLabel : mutedToastLabel);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,14 +5,11 @@ import { useTheme } from "next-themes";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSound } from "@/components/sound-provider";
|
import { useSound } from "@/components/sound-provider";
|
||||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||||
import { toast } from "@/lib/toast";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ThemeToggleProps = {
|
type ThemeToggleProps = {
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
lightToastLabel?: string;
|
|
||||||
darkToastLabel?: string;
|
|
||||||
variant?: ButtonProps["variant"];
|
variant?: ButtonProps["variant"];
|
||||||
className?: string;
|
className?: string;
|
||||||
iconClassName?: string;
|
iconClassName?: string;
|
||||||
@@ -21,8 +18,6 @@ type ThemeToggleProps = {
|
|||||||
export function ThemeToggle({
|
export function ThemeToggle({
|
||||||
ariaLabel = "Toggle theme",
|
ariaLabel = "Toggle theme",
|
||||||
label,
|
label,
|
||||||
lightToastLabel = "Light mode enabled",
|
|
||||||
darkToastLabel = "Dark mode enabled",
|
|
||||||
variant = "outline",
|
variant = "outline",
|
||||||
className,
|
className,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
@@ -58,7 +53,6 @@ export function ThemeToggle({
|
|||||||
|
|
||||||
setTheme(nextTheme);
|
setTheme(nextTheme);
|
||||||
playSound(nextTheme === "dark" ? "/audio/dark.mp3" : "/audio/light.mp3");
|
playSound(nextTheme === "dark" ? "/audio/dark.mp3" : "/audio/light.mp3");
|
||||||
toast(nextTheme === "dark" ? darkToastLabel : lightToastLabel);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
lib/mail.ts
|
||||||
Handles SMTP delivery via nodemailer.
|
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.
|
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
|
app/[locale]/(site)/contact/page.tsx
|
||||||
|
|
||||||
2. Server action validates the input using Zod.
|
2. Server action validates the input using Zod.
|
||||||
3. Turnstile verification and rate limiting run via:
|
3. Email is sent through:
|
||||||
|
|
||||||
lib/contact-guard.ts
|
|
||||||
|
|
||||||
4. Email is sent through:
|
|
||||||
|
|
||||||
lib/mail.ts
|
lib/mail.ts
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,7 @@
|
|||||||
|
|
||||||
### Current implementation
|
### Current implementation
|
||||||
|
|
||||||
- Contact submission requires valid name, email, and message
|
- Contact submission requires valid name, email, and message (validated with Zod)
|
||||||
- Turnstile is optional and controlled by settings
|
|
||||||
- Rate limiting is optional and keyed by hashed client IP plus time window
|
|
||||||
- Successful submission sends email only
|
- Successful submission sends email only
|
||||||
- No submission record is stored in the database
|
- No submission record is stored in the database
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,6 @@
|
|||||||
- `CASE_STUDY`
|
- `CASE_STUDY`
|
||||||
- Contact form with:
|
- Contact form with:
|
||||||
- validation
|
- validation
|
||||||
- optional Turnstile
|
|
||||||
- rate limiting
|
|
||||||
- email delivery
|
- email delivery
|
||||||
- Success page after contact submission
|
- Success page after contact submission
|
||||||
- Maintenance redirect flow
|
- Maintenance redirect flow
|
||||||
@@ -29,7 +27,6 @@
|
|||||||
- Media library with usage bindings
|
- Media library with usage bindings
|
||||||
- Site settings management
|
- Site settings management
|
||||||
- SMTP settings and test email
|
- SMTP settings and test email
|
||||||
- Contact protection settings
|
|
||||||
- Marquee settings
|
- Marquee settings
|
||||||
- Maintenance toggle
|
- Maintenance toggle
|
||||||
- UI Kit preview page
|
- 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
|
- Site Settings
|
||||||
- Marquee Settings
|
- Marquee Settings
|
||||||
- SMTP Settings
|
- SMTP Settings
|
||||||
- Contact Protection
|
|
||||||
- Portfolio management
|
- Portfolio management
|
||||||
|
|
||||||
### Data model
|
### Data model
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ Patterns:
|
|||||||
Repeated in:
|
Repeated in:
|
||||||
|
|
||||||
- `components/dashboard/dashboard-layout.tsx`
|
- `components/dashboard/dashboard-layout.tsx`
|
||||||
- `components/root/root-dashboard-shell.tsx`
|
- `components/admin/root-dashboard-shell.tsx`
|
||||||
- many `app/root/*/page.tsx`
|
- many `app/root/*/page.tsx`
|
||||||
|
|
||||||
Patterns:
|
Patterns:
|
||||||
@@ -160,8 +160,8 @@ Patterns:
|
|||||||
|
|
||||||
Repeated in:
|
Repeated in:
|
||||||
|
|
||||||
- `components/root/site-settings-form.tsx`
|
- `components/admin/site-settings-form.tsx`
|
||||||
- `components/root/portfolio-project-form.tsx`
|
- `components/admin/portfolio-project-form.tsx`
|
||||||
- `app/root/page.tsx`
|
- `app/root/page.tsx`
|
||||||
|
|
||||||
Patterns:
|
Patterns:
|
||||||
@@ -204,7 +204,7 @@ Most repeated token usage:
|
|||||||
- `components/ui/textarea.tsx`
|
- `components/ui/textarea.tsx`
|
||||||
- `components/ui/select.tsx`
|
- `components/ui/select.tsx`
|
||||||
- `components/dashboard/sidebar.tsx`
|
- `components/dashboard/sidebar.tsx`
|
||||||
- `components/root/contact-protection-form.tsx`
|
- `components/admin/contact-protection-form.tsx`
|
||||||
- `rounded-[var(--radius-pill)]`
|
- `rounded-[var(--radius-pill)]`
|
||||||
- `components/layout/site-header.tsx`
|
- `components/layout/site-header.tsx`
|
||||||
- `components/layout/floating-preferences.tsx`
|
- `components/layout/floating-preferences.tsx`
|
||||||
@@ -220,13 +220,13 @@ Most repeated token usage:
|
|||||||
- several content blocks and previews
|
- several content blocks and previews
|
||||||
- examples:
|
- examples:
|
||||||
- `app/root/page.tsx`
|
- `app/root/page.tsx`
|
||||||
- `components/root/site-settings-form.tsx`
|
- `components/admin/site-settings-form.tsx`
|
||||||
- `components/root/media-field-picker.tsx`
|
- `components/admin/media-field-picker.tsx`
|
||||||
- `rounded-lg`
|
- `rounded-lg`
|
||||||
- examples:
|
- examples:
|
||||||
- `components/ui/dialog.tsx`
|
- `components/ui/dialog.tsx`
|
||||||
- `components/dashboard/dashboard-layout.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
|
## 6. Repeated spacing patterns found in code
|
||||||
|
|
||||||
@@ -321,14 +321,14 @@ Very common combinations:
|
|||||||
- `border border-input bg-background`
|
- `border border-input bg-background`
|
||||||
- `components/ui/input.tsx`
|
- `components/ui/input.tsx`
|
||||||
- `components/ui/select.tsx`
|
- `components/ui/select.tsx`
|
||||||
- `components/root/portfolio-categories-manager.tsx`
|
- `components/admin/portfolio-categories-manager.tsx`
|
||||||
- `components/root/portfolio-projects-overview.tsx`
|
- `components/admin/portfolio-projects-overview.tsx`
|
||||||
- `bg-primary text-primary-foreground`
|
- `bg-primary text-primary-foreground`
|
||||||
- active nav and badges/buttons:
|
- active nav and badges/buttons:
|
||||||
- `components/ui/button.tsx`
|
- `components/ui/button.tsx`
|
||||||
- `components/ui/badge.tsx`
|
- `components/ui/badge.tsx`
|
||||||
- `components/dashboard/sidebar.tsx`
|
- `components/dashboard/sidebar.tsx`
|
||||||
- `components/root/portfolio-subnav.tsx`
|
- `components/admin/portfolio-subnav.tsx`
|
||||||
- `app/[locale]/(site)/portfolio/page.tsx`
|
- `app/[locale]/(site)/portfolio/page.tsx`
|
||||||
- `text-muted-foreground`
|
- `text-muted-foreground`
|
||||||
- repeated throughout forms, cards, table headers, descriptions
|
- repeated throughout forms, cards, table headers, descriptions
|
||||||
@@ -406,7 +406,7 @@ Evidence:
|
|||||||
- dependency in `package.json`
|
- dependency in `package.json`
|
||||||
- broad usage across app and components
|
- broad usage across app and components
|
||||||
- examples:
|
- examples:
|
||||||
- `components/root/root-dashboard-shell.tsx`
|
- `components/admin/root-dashboard-shell.tsx`
|
||||||
- `components/layout/site-header.tsx`
|
- `components/layout/site-header.tsx`
|
||||||
- `components/theme-toggle.tsx`
|
- `components/theme-toggle.tsx`
|
||||||
- `app/root/page.tsx`
|
- `app/root/page.tsx`
|
||||||
@@ -422,8 +422,8 @@ No second React icon library is present in `package.json`.
|
|||||||
- public site routes live under:
|
- public site routes live under:
|
||||||
- `app/[locale]/(site)/*`
|
- `app/[locale]/(site)/*`
|
||||||
- admin routes live under:
|
- admin routes live under:
|
||||||
- `app/root/*`
|
- `app/_admin/*` (canonical source), mirrored to `app/admin-internal/*` (rewrite target) and `app/root/*` (dev alias)
|
||||||
- `src` exists but is empty in the current codebase.
|
- there is no `src/` directory in the codebase.
|
||||||
|
|
||||||
### Component folders
|
### Component folders
|
||||||
|
|
||||||
@@ -431,7 +431,7 @@ No second React icon library is present in `package.json`.
|
|||||||
- primitive and near-primitive reusable controls
|
- primitive and near-primitive reusable controls
|
||||||
- `components/layout`
|
- `components/layout`
|
||||||
- site shell, hero, header, footer, container, backdrops
|
- site shell, hero, header, footer, container, backdrops
|
||||||
- `components/root`
|
- `components/admin`
|
||||||
- admin feature components and forms
|
- admin feature components and forms
|
||||||
- `components/dashboard`
|
- `components/dashboard`
|
||||||
- admin navigation and dashboard layout shell
|
- admin navigation and dashboard layout shell
|
||||||
@@ -442,7 +442,7 @@ No second React icon library is present in `package.json`.
|
|||||||
|
|
||||||
- `lib/utils.ts`
|
- `lib/utils.ts`
|
||||||
- `cn()`
|
- `cn()`
|
||||||
- `lib/root-navigation.ts`
|
- `lib/admin-navigation.ts`
|
||||||
- navigation config for admin shell
|
- navigation config for admin shell
|
||||||
- other `lib/*`
|
- other `lib/*`
|
||||||
- app settings, metadata, locale, media, portfolio, auth
|
- app settings, metadata, locale, media, portfolio, auth
|
||||||
@@ -457,7 +457,7 @@ Confirmed custom wrappers around local primitive layer:
|
|||||||
- wraps `Button`
|
- wraps `Button`
|
||||||
- `components/layout/locale-toggle.tsx`
|
- `components/layout/locale-toggle.tsx`
|
||||||
- uses `Button` and `DropdownMenu`
|
- uses `Button` and `DropdownMenu`
|
||||||
- `components/root/form-save-button.tsx`
|
- `components/admin/form-save-button.tsx`
|
||||||
- uses `Button`
|
- uses `Button`
|
||||||
|
|
||||||
## 11. Violations or inconsistencies found in the current codebase
|
## 11. Violations or inconsistencies found in the current codebase
|
||||||
@@ -498,8 +498,8 @@ Examples:
|
|||||||
|
|
||||||
- `components/ui/dialog.tsx`
|
- `components/ui/dialog.tsx`
|
||||||
- `components/dashboard/dashboard-layout.tsx`
|
- `components/dashboard/dashboard-layout.tsx`
|
||||||
- `components/root/site-settings-form.tsx`
|
- `components/admin/site-settings-form.tsx`
|
||||||
- `components/root/portfolio-project-form.tsx`
|
- `components/admin/portfolio-project-form.tsx`
|
||||||
|
|
||||||
### Some primitives follow the local system, some keep stock shadcn-style values
|
### 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.
|
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`
|
`tailwind.config.ts`
|
||||||
includes:
|
includes:
|
||||||
@@ -538,7 +538,7 @@ includes:
|
|||||||
|
|
||||||
Current project state:
|
Current project state:
|
||||||
|
|
||||||
- `src` contains no files.
|
- there is no `src/` directory; this glob matches nothing.
|
||||||
|
|
||||||
## 12. Codex rules derived from the existing system
|
## 12. Codex rules derived from the existing system
|
||||||
|
|
||||||
|
|||||||
@@ -215,11 +215,11 @@ Current feature code now reuses these levels in shared admin surfaces.
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
- `components/root/media-field-picker.tsx`
|
- `components/admin/media-field-picker.tsx`
|
||||||
- `components/root/portfolio-projects-overview.tsx`
|
- `components/admin/portfolio-projects-overview.tsx`
|
||||||
- `components/root/portfolio-project-form.tsx`
|
- `components/admin/portfolio-project-form.tsx`
|
||||||
- `components/root/portfolio-categories-manager.tsx`
|
- `components/admin/portfolio-categories-manager.tsx`
|
||||||
- `components/root/site-settings-form.tsx`
|
- `components/admin/site-settings-form.tsx`
|
||||||
|
|
||||||
## 6. Current input/button/select/dialog/sheet rules in use
|
## 6. Current input/button/select/dialog/sheet rules in use
|
||||||
|
|
||||||
@@ -485,7 +485,7 @@ Examples:
|
|||||||
- `components/ui/select.tsx`
|
- `components/ui/select.tsx`
|
||||||
- `components/layout/site-header.tsx`
|
- `components/layout/site-header.tsx`
|
||||||
- `components/theme-toggle.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:
|
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/page.tsx`
|
||||||
- `app/[locale]/(site)/portfolio/[slug]/page.tsx`
|
- `app/[locale]/(site)/portfolio/[slug]/page.tsx`
|
||||||
- one custom dark preview surface remains in current admin code
|
- 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`
|
- `bg-slate-950`
|
||||||
- `text-white/55`
|
- `text-white/55`
|
||||||
- `text-white/60`
|
- `text-white/60`
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ Repeated nested panels and stat blocks in feature code were moved toward shared
|
|||||||
|
|
||||||
Refactored areas include:
|
Refactored areas include:
|
||||||
|
|
||||||
- `components/root/media-field-picker.tsx`
|
- `components/admin/media-field-picker.tsx`
|
||||||
- `components/root/portfolio-projects-overview.tsx`
|
- `components/admin/portfolio-projects-overview.tsx`
|
||||||
- `components/root/portfolio-project-form.tsx`
|
- `components/admin/portfolio-project-form.tsx`
|
||||||
- `components/root/portfolio-categories-manager.tsx`
|
- `components/admin/portfolio-categories-manager.tsx`
|
||||||
- `components/root/site-settings-form.tsx`
|
- `components/admin/site-settings-form.tsx`
|
||||||
|
|
||||||
### 5. False abstraction fixed
|
### 5. False abstraction fixed
|
||||||
|
|
||||||
@@ -113,13 +113,13 @@ Current `AppCard` levels are now meaningfully distinct:
|
|||||||
- `components/dashboard/dashboard-layout.tsx`
|
- `components/dashboard/dashboard-layout.tsx`
|
||||||
- `components/layout/floating-preferences.tsx`
|
- `components/layout/floating-preferences.tsx`
|
||||||
- `components/layout/site-header.tsx`
|
- `components/layout/site-header.tsx`
|
||||||
- `components/root/flash-message.tsx`
|
- `components/admin/flash-message.tsx`
|
||||||
- `components/root/media-field-picker.tsx`
|
- `components/admin/media-field-picker.tsx`
|
||||||
- `components/root/portfolio-categories-manager.tsx`
|
- `components/admin/portfolio-categories-manager.tsx`
|
||||||
- `components/root/portfolio-project-form.tsx`
|
- `components/admin/portfolio-project-form.tsx`
|
||||||
- `components/root/portfolio-projects-overview.tsx`
|
- `components/admin/portfolio-projects-overview.tsx`
|
||||||
- `components/root/portfolio-subnav.tsx`
|
- `components/admin/portfolio-subnav.tsx`
|
||||||
- `components/root/site-settings-form.tsx`
|
- `components/admin/site-settings-form.tsx`
|
||||||
- `components/ui/app-card.tsx`
|
- `components/ui/app-card.tsx`
|
||||||
- `components/ui/dialog.tsx`
|
- `components/ui/dialog.tsx`
|
||||||
- `components/ui/sheet.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;
|
localizationSettings?: string;
|
||||||
marquee?: string;
|
marquee?: string;
|
||||||
smtp?: string;
|
smtp?: string;
|
||||||
contactProtection?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminNavItem = {
|
export type AdminNavItem = {
|
||||||
@@ -41,7 +40,6 @@ export type AdminNavItem = {
|
|||||||
export function getAdminNavigation(
|
export function getAdminNavigation(
|
||||||
copy: AdminNavigationCopy,
|
copy: AdminNavigationCopy,
|
||||||
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
|
active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
|
||||||
smtpChild?: "settings" | "contact-protection",
|
|
||||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
||||||
siteSettingsChild?: "brand" | "localization",
|
siteSettingsChild?: "brand" | "localization",
|
||||||
): AdminNavItem[] {
|
): AdminNavItem[] {
|
||||||
@@ -101,22 +99,7 @@ export function getAdminNavigation(
|
|||||||
label: copy.smtp ?? "SMTP",
|
label: copy.smtp ?? "SMTP",
|
||||||
href: getAdminAppPath("/smtp"),
|
href: getAdminAppPath("/smtp"),
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
active: active === "smtp" && !smtpChild,
|
active: active === "smtp",
|
||||||
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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: copy.portfolio,
|
label: copy.portfolio,
|
||||||
|
|||||||
@@ -24,16 +24,6 @@ export {
|
|||||||
type MailSettings,
|
type MailSettings,
|
||||||
type MailSettingsFormValues,
|
type MailSettingsFormValues,
|
||||||
} from "./mail-settings";
|
} from "./mail-settings";
|
||||||
export {
|
|
||||||
CONTACT_PROTECTION_SETTINGS_KEY,
|
|
||||||
buildDefaultContactProtectionSettings,
|
|
||||||
parseContactProtectionValue,
|
|
||||||
toContactProtectionFormValues,
|
|
||||||
toPublicContactProtectionSettings,
|
|
||||||
type ContactProtectionSettings,
|
|
||||||
type ContactProtectionFormValues,
|
|
||||||
type PublicContactProtectionSettings,
|
|
||||||
} from "./contact-protection";
|
|
||||||
export {
|
export {
|
||||||
MARQUEE_SETTINGS_KEY,
|
MARQUEE_SETTINGS_KEY,
|
||||||
buildDefaultMarqueeSettings,
|
buildDefaultMarqueeSettings,
|
||||||
@@ -67,16 +57,6 @@ import {
|
|||||||
type MailSettings,
|
type MailSettings,
|
||||||
type MailSettingsFormValues,
|
type MailSettingsFormValues,
|
||||||
} from "./mail-settings";
|
} from "./mail-settings";
|
||||||
import {
|
|
||||||
CONTACT_PROTECTION_SETTINGS_KEY,
|
|
||||||
buildDefaultContactProtectionSettings,
|
|
||||||
parseContactProtectionValue,
|
|
||||||
toContactProtectionFormValues,
|
|
||||||
toPublicContactProtectionSettings,
|
|
||||||
type ContactProtectionSettings,
|
|
||||||
type ContactProtectionFormValues,
|
|
||||||
type PublicContactProtectionSettings,
|
|
||||||
} from "./contact-protection";
|
|
||||||
import {
|
import {
|
||||||
MARQUEE_SETTINGS_KEY,
|
MARQUEE_SETTINGS_KEY,
|
||||||
buildDefaultMarqueeSettings,
|
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> {
|
export async function getMarqueeSettings(): Promise<MarqueeSettings> {
|
||||||
try {
|
try {
|
||||||
const config = await prisma.appConfig.findUnique({
|
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": "فتح القائمة",
|
"openMenu": "فتح القائمة",
|
||||||
"closeMenu": "إغلاق القائمة",
|
"closeMenu": "إغلاق القائمة",
|
||||||
"themeToggle": "تبديل المظهر",
|
"themeToggle": "تبديل المظهر",
|
||||||
"themeLight": "تم تفعيل الوضع الفاتح",
|
|
||||||
"themeDark": "تم تفعيل الوضع الداكن",
|
|
||||||
"soundMute": "كتم الأصوات",
|
"soundMute": "كتم الأصوات",
|
||||||
"soundUnmute": "تشغيل الأصوات",
|
"soundUnmute": "تشغيل الأصوات"
|
||||||
"soundMuted": "تم كتم الصوت",
|
|
||||||
"soundEnabled": "تم تشغيل الصوت",
|
|
||||||
"localeChangedDe": "تم تغيير اللغة إلى الألمانية",
|
|
||||||
"localeChangedEn": "تم تغيير اللغة إلى الإنجليزية",
|
|
||||||
"localeChangedAr": "تم تغيير اللغة إلى العربية",
|
|
||||||
"logoTripleClick": "هدي اللعب... اللوغو مو زر طوارئ."
|
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"line": {
|
"line": {
|
||||||
|
|||||||
+1
-9
@@ -10,16 +10,8 @@
|
|||||||
"openMenu": "Menü öffnen",
|
"openMenu": "Menü öffnen",
|
||||||
"closeMenu": "Menü schliessen",
|
"closeMenu": "Menü schliessen",
|
||||||
"themeToggle": "Theme wechseln",
|
"themeToggle": "Theme wechseln",
|
||||||
"themeLight": "Heller Modus aktiviert",
|
|
||||||
"themeDark": "Dunkler Modus aktiviert",
|
|
||||||
"soundMute": "Sounds stummschalten",
|
"soundMute": "Sounds stummschalten",
|
||||||
"soundUnmute": "Sounds aktivieren",
|
"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."
|
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"line": {
|
"line": {
|
||||||
|
|||||||
+1
-9
@@ -10,16 +10,8 @@
|
|||||||
"openMenu": "Open menu",
|
"openMenu": "Open menu",
|
||||||
"closeMenu": "Close menu",
|
"closeMenu": "Close menu",
|
||||||
"themeToggle": "Toggle theme",
|
"themeToggle": "Toggle theme",
|
||||||
"themeLight": "Light mode enabled",
|
|
||||||
"themeDark": "Dark mode enabled",
|
|
||||||
"soundMute": "Mute sounds",
|
"soundMute": "Mute sounds",
|
||||||
"soundUnmute": "Unmute 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."
|
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"line": {
|
"line": {
|
||||||
|
|||||||
Generated
+1
-27
@@ -30,7 +30,6 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-hot-toast": "^2.6.0",
|
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
@@ -5459,6 +5458,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
@@ -6971,15 +6971,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -9228,23 +9219,6 @@
|
|||||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
|||||||
@@ -39,7 +39,6 @@
|
|||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
"react-hot-toast": "^2.6.0",
|
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"zod": "^4.3.6"
|
"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