From 1d4c2f8e726241a48317d31ea842a0695ffb3579 Mon Sep 17 00:00:00 2001 From: MOH Date: Sun, 8 Mar 2026 22:40:01 +0100 Subject: [PATCH] Add editable localized marquee settings --- app/[locale]/(site)/page.tsx | 17 +- app/root/marquee/actions.ts | 93 ++++++++++ app/root/marquee/page.tsx | 84 +++++++++ components/layout/stacked-marquee-section.tsx | 170 ++++++++++++++++++ components/root/marquee-settings-form.tsx | 63 +++++++ components/root/root-dashboard-shell.tsx | 8 +- lib/app-config.ts | 40 +++++ lib/marquee-settings.ts | 128 +++++++++++++ lib/root-navigation.ts | 10 +- 9 files changed, 608 insertions(+), 5 deletions(-) create mode 100644 app/root/marquee/actions.ts create mode 100644 app/root/marquee/page.tsx create mode 100644 components/layout/stacked-marquee-section.tsx create mode 100644 components/root/marquee-settings-form.tsx create mode 100644 lib/marquee-settings.ts diff --git a/app/[locale]/(site)/page.tsx b/app/[locale]/(site)/page.tsx index 860e178..ca52e49 100644 --- a/app/[locale]/(site)/page.tsx +++ b/app/[locale]/(site)/page.tsx @@ -14,8 +14,9 @@ import { getTranslations } from "next-intl/server"; import { Container } from "@/components/layout/container"; import { HomeHero } from "@/components/layout/home-hero"; +import { StackedMarqueeSection, type MarqueeRow } from "@/components/layout/stacked-marquee-section"; import { MotionFade } from "@/components/motion-fade"; -import { getSiteSettings } from "@/lib/app-config"; +import { getMarqueeSettings, getSiteSettings, splitMarqueeRowItems } from "@/lib/app-config"; import { buildLocalizedMetadata } from "@/lib/metadata"; import { AppCard } from "@/components/ui/app-card"; import { Button } from "@/components/ui/button"; @@ -60,7 +61,10 @@ export async function generateMetadata({ export default async function HomePage({ params: { locale } }: HomePageProps) { const localeKey = resolveLocale(locale); - const t = await getTranslations({ locale: localeKey, namespace: "homepage" }); + const [t, marqueeSettings] = await Promise.all([ + getTranslations({ locale: localeKey, namespace: "homepage" }), + getMarqueeSettings(), + ]); const serviceItems = [ t("serviceBrand"), t("serviceWebsite"), @@ -70,6 +74,13 @@ export default async function HomePage({ params: { locale } }: HomePageProps) { t("serviceTracking"), t("serviceSystem"), ]; + const marqueeLocale = marqueeSettings.locales[localeKey]; + const marqueeRows: MarqueeRow[] = [ + { direction: "left", duration: 126, items: splitMarqueeRowItems(marqueeLocale.row1) }, + { direction: "right", duration: 220, items: splitMarqueeRowItems(marqueeLocale.row2) }, + { direction: "left", duration: 168, items: splitMarqueeRowItems(marqueeLocale.row3) }, + { direction: "right", duration: 144, items: splitMarqueeRowItems(marqueeLocale.row4) }, + ]; return ( <> @@ -141,6 +152,8 @@ export default async function HomePage({ params: { locale } }: HomePageProps) { + + ); } diff --git a/app/root/marquee/actions.ts b/app/root/marquee/actions.ts new file mode 100644 index 0000000..01e5f79 --- /dev/null +++ b/app/root/marquee/actions.ts @@ -0,0 +1,93 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { isRedirectError } from "next/dist/client/components/redirect"; + +import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; +import { updateMarqueeSettings } from "@/lib/app-config"; +import { routing } from "@/i18n/routing"; +import { getLocalizedPath } from "@/lib/locale"; + +function ensureAdmin() { + if (!isAdminAuthenticated()) { + clearAdminSessionCookie(); + redirect("/root"); + } +} + +function withMessage(pathname: string, type: "success" | "error", message: string) { + const params = new URLSearchParams(); + params.set(type, message); + + return `${pathname}?${params.toString()}`; +} + +async function revalidateMarqueePages() { + revalidatePath("/root"); + revalidatePath("/root/marquee"); + + for (const locale of routing.locales) { + revalidatePath(getLocalizedPath(locale), "layout"); + revalidatePath(getLocalizedPath(locale)); + } +} + +export async function saveMarqueeSettingsAction(formData: FormData) { + ensureAdmin(); + + try { + const settings = { + locales: { + ar: { + row1: String(formData.get("row1-ar") ?? "").trim(), + row2: String(formData.get("row2-ar") ?? "").trim(), + row3: String(formData.get("row3-ar") ?? "").trim(), + row4: String(formData.get("row4-ar") ?? "").trim(), + }, + en: { + row1: String(formData.get("row1-en") ?? "").trim(), + row2: String(formData.get("row2-en") ?? "").trim(), + row3: String(formData.get("row3-en") ?? "").trim(), + row4: String(formData.get("row4-en") ?? "").trim(), + }, + de: { + row1: String(formData.get("row1-de") ?? "").trim(), + row2: String(formData.get("row2-de") ?? "").trim(), + row3: String(formData.get("row3-de") ?? "").trim(), + row4: String(formData.get("row4-de") ?? "").trim(), + }, + }, + }; + + for (const locale of routing.locales) { + if (!settings.locales[locale].row1) { + throw new Error(`Row 1 fuer ${locale} ist erforderlich.`); + } + + if (!settings.locales[locale].row2) { + throw new Error(`Row 2 fuer ${locale} ist erforderlich.`); + } + + if (!settings.locales[locale].row3) { + throw new Error(`Row 3 fuer ${locale} ist erforderlich.`); + } + + if (!settings.locales[locale].row4) { + throw new Error(`Row 4 fuer ${locale} ist erforderlich.`); + } + } + + await updateMarqueeSettings(settings); + await revalidateMarqueePages(); + + redirect(withMessage("/root/marquee", "success", "Marquee gespeichert.")); + } catch (error) { + if (isRedirectError(error)) { + throw error; + } + + const message = error instanceof Error ? error.message : "Marquee konnte nicht gespeichert werden."; + redirect(withMessage("/root/marquee", "error", message)); + } +} diff --git a/app/root/marquee/page.tsx b/app/root/marquee/page.tsx new file mode 100644 index 0000000..d35e579 --- /dev/null +++ b/app/root/marquee/page.tsx @@ -0,0 +1,84 @@ +import { redirect } from "next/navigation"; + +import { FlashMessage } from "@/components/root/flash-message"; +import { MarqueeSettingsForm } from "@/components/root/marquee-settings-form"; +import { RootDashboardShell } from "@/components/root/root-dashboard-shell"; +import { MotionFade } from "@/components/motion-fade"; +import { clearAdminSessionCookie, isAdminAuthenticated } from "@/lib/admin-auth"; +import { getMarqueeSettings } from "@/lib/app-config"; + +import { saveMarqueeSettingsAction } from "./actions"; + +export const dynamic = "force-dynamic"; + +const copy = { + title: "Marquee", + subtitle: "Scrollende Textzeilen fuer die Startseite pro Sprache verwalten.", + overview: "Uebersicht", + maintenance: "Wartungsmodus", + uiKit: "UI Kit", + media: "Media", + siteSettings: "SEO", + marquee: "Marquee", + smtp: "SMTP", + portfolio: "Portfolio", + logout: "Ausloggen", + backToSite: "Zur Website", +}; + +type RootMarqueePageProps = { + searchParams?: { + success?: string; + error?: string; + }; +}; + +export default async function RootMarqueePage({ + searchParams, +}: RootMarqueePageProps) { + if (!isAdminAuthenticated()) { + redirect("/root"); + } + + async function logoutAction() { + "use server"; + + clearAdminSessionCookie(); + redirect("/root"); + } + + const marqueeSettings = await getMarqueeSettings(); + + return ( + +
+ {searchParams?.success ? ( + + + + ) : null} + + {searchParams?.error ? ( + + + + ) : null} + + + + +
+
+ ); +} diff --git a/components/layout/stacked-marquee-section.tsx b/components/layout/stacked-marquee-section.tsx new file mode 100644 index 0000000..518bbeb --- /dev/null +++ b/components/layout/stacked-marquee-section.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { motion, useReducedMotion } from "framer-motion"; + +import { cn } from "@/lib/utils"; + +type MarqueeDirection = "left" | "right"; + +export type MarqueeRow = { + items: string[]; + direction: MarqueeDirection; + duration?: number; +}; + +type StackedMarqueeSectionProps = { + className?: string; + rows?: MarqueeRow[]; +}; + +const DEFAULT_ROWS: MarqueeRow[] = [ + { + direction: "left", + duration: 126, + items: [ + "Next.js", + "React", + "WordPress", + "Docker", + "Technical SEO", + "Tailwind CSS", + "Google Analytics", + "Framer Motion", + "Cloudflare", + "Performance", + ], + }, + { + direction: "right", + duration: 220, + items: [ + "TypeScript", + "Node.js", + "WooCommerce", + "Schema Markup", + "DevOps", + "Responsive Design", + "Core Web Vitals", + "Plausible Analytics", + "Design Systems", + "Server Infrastructure", + ], + }, + { + direction: "left", + duration: 168, + items: [ + "JavaScript", + "REST APIs", + "Git", + "Linux", + "Search Console", + "Conversion Tracking", + "UI Engineering", + "Accessibility", + "Component Architecture", + "Web Performance", + ], + }, + { + direction: "right", + duration: 144, + items: [ + "Frontend Strategy", + "CMS Integration", + "Motion Design", + "Content Architecture", + "E-Commerce", + "SEO Audits", + "Deployments", + "Scalable UI", + "Analytics Setup", + "Product Pages", + ], + }, +]; + +function MarqueeRowTrack({ items, direction, duration = 24 }: MarqueeRow) { + const shouldReduceMotion = useReducedMotion(); + + return ( +
+ + {Array.from({ length: 2 }).map((_, copyIndex) => ( +
+ {items.map((item, itemIndex) => ( +
+ + {item} + + +
+ ))} +
+ ))} +
+
+ ); +} + +export function StackedMarqueeSection({ + className, + rows = DEFAULT_ROWS, +}: StackedMarqueeSectionProps) { + return ( +
+
+ {rows.map((row, index) => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/components/root/marquee-settings-form.tsx b/components/root/marquee-settings-form.tsx new file mode 100644 index 0000000..161e4d5 --- /dev/null +++ b/components/root/marquee-settings-form.tsx @@ -0,0 +1,63 @@ +import { AppCard } from "@/components/ui/app-card"; +import { CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import type { MarqueeSettings } from "@/lib/marquee-settings"; + +type MarqueeSettingsFormProps = { + action: (formData: FormData) => Promise; + initialSettings: MarqueeSettings; +}; + +const localeMeta = [ + { key: "de", label: "Deutsch" }, + { key: "en", label: "English" }, + { key: "ar", label: "Arabic" }, +] as const; + +const rowMeta = [ + { key: "row1", label: "Row 1" }, + { key: "row2", label: "Row 2" }, + { key: "row3", label: "Row 3" }, + { key: "row4", label: "Row 4" }, +] as const; + +export function MarqueeSettingsForm({ + action, + initialSettings, +}: MarqueeSettingsFormProps) { + return ( +
+ + + Marquee Rows + Ein Feld pro Zeile. Ein Begriff pro Zeile oder mit Komma getrennt. + + + {localeMeta.map((locale) => ( +
+
+

{locale.label}

+

Vier Marquee Zeilen fuer diese Sprache.

+
+ +
+ {rowMeta.map((row) => ( +
+ +