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 (
+
+ );
+}
diff --git a/components/root/root-dashboard-shell.tsx b/components/root/root-dashboard-shell.tsx
index ef6e43f..ba7bad6 100644
--- a/components/root/root-dashboard-shell.tsx
+++ b/components/root/root-dashboard-shell.tsx
@@ -10,6 +10,7 @@ import {
ShieldAlert,
SwatchBook,
Tags,
+ Type,
} from "lucide-react";
import Link from "next/link";
@@ -35,6 +36,7 @@ type RootDashboardCopy = {
portfolio: string;
media: string;
siteSettings: string;
+ marquee?: string;
smtp?: string;
contactProtection?: string;
logout: string;
@@ -43,7 +45,7 @@ type RootDashboardCopy = {
type RootDashboardShellProps = {
copy: RootDashboardCopy;
- active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp";
+ active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee";
smtpChild?: "settings" | "contact-protection";
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
logoutAction: () => Promise;
@@ -92,9 +94,11 @@ export async function RootDashboardShell({
? SwatchBook
: active === "site-settings"
? Globe2
+ : active === "marquee"
+ ? Type
: active === "smtp"
? ShieldAlert
- : active === "media"
+ : active === "media"
? ImageIcon
: portfolioChild === "categories"
? Tags
diff --git a/lib/app-config.ts b/lib/app-config.ts
index 0b8acff..3d1d2bf 100644
--- a/lib/app-config.ts
+++ b/lib/app-config.ts
@@ -34,6 +34,14 @@ export {
type ContactProtectionFormValues,
type PublicContactProtectionSettings,
} from "./contact-protection";
+export {
+ MARQUEE_SETTINGS_KEY,
+ buildDefaultMarqueeSettings,
+ parseMarqueeSettingsValue,
+ splitMarqueeRowItems,
+ type MarqueeLocaleSettings,
+ type MarqueeSettings,
+} from "./marquee-settings";
import {
DEFAULT_SITE_NAME,
SITE_NAME_KEY,
@@ -68,6 +76,12 @@ import {
type ContactProtectionFormValues,
type PublicContactProtectionSettings,
} from "./contact-protection";
+import {
+ MARQUEE_SETTINGS_KEY,
+ buildDefaultMarqueeSettings,
+ parseMarqueeSettingsValue,
+ type MarqueeSettings,
+} from "./marquee-settings";
export async function getMaintenanceMode(): Promise {
try {
@@ -203,6 +217,32 @@ export async function updateContactProtectionSettings(
});
}
+export async function getMarqueeSettings(): Promise {
+ try {
+ const config = await prisma.appConfig.findUnique({
+ where: { key: MARQUEE_SETTINGS_KEY },
+ select: { value: true },
+ });
+
+ return parseMarqueeSettingsValue(config?.value);
+ } catch {
+ return buildDefaultMarqueeSettings();
+ }
+}
+
+export async function updateMarqueeSettings(settings: MarqueeSettings): Promise {
+ await prisma.appConfig.upsert({
+ where: { key: MARQUEE_SETTINGS_KEY },
+ update: {
+ value: JSON.stringify(settings),
+ },
+ create: {
+ key: MARQUEE_SETTINGS_KEY,
+ value: JSON.stringify(settings),
+ },
+ });
+}
+
export async function getSiteSettingsMediaBindings(): Promise {
try {
const usages = await prisma.mediaUsage.findMany({
diff --git a/lib/marquee-settings.ts b/lib/marquee-settings.ts
new file mode 100644
index 0000000..35aea8b
--- /dev/null
+++ b/lib/marquee-settings.ts
@@ -0,0 +1,128 @@
+export const MARQUEE_SETTINGS_KEY = "marquee_settings";
+
+export type MarqueeLocaleSettings = {
+ row1: string;
+ row2: string;
+ row3: string;
+ row4: string;
+};
+
+export type MarqueeSettings = {
+ locales: {
+ ar: MarqueeLocaleSettings;
+ en: MarqueeLocaleSettings;
+ de: MarqueeLocaleSettings;
+ };
+};
+
+const DEFAULT_ROW_1 = [
+ "Next.js",
+ "React",
+ "WordPress",
+ "Docker",
+ "Technical SEO",
+ "Tailwind CSS",
+ "Google Analytics",
+ "Framer Motion",
+ "Cloudflare",
+ "Performance",
+].join("\n");
+
+const DEFAULT_ROW_2 = [
+ "TypeScript",
+ "Node.js",
+ "WooCommerce",
+ "Schema Markup",
+ "DevOps",
+ "Responsive Design",
+ "Core Web Vitals",
+ "Plausible Analytics",
+ "Design Systems",
+ "Server Infrastructure",
+].join("\n");
+
+const DEFAULT_ROW_3 = [
+ "JavaScript",
+ "REST APIs",
+ "Git",
+ "Linux",
+ "Search Console",
+ "Conversion Tracking",
+ "UI Engineering",
+ "Accessibility",
+ "Component Architecture",
+ "Web Performance",
+].join("\n");
+
+const DEFAULT_ROW_4 = [
+ "Frontend Strategy",
+ "CMS Integration",
+ "Motion Design",
+ "Content Architecture",
+ "E-Commerce",
+ "SEO Audits",
+ "Deployments",
+ "Scalable UI",
+ "Analytics Setup",
+ "Product Pages",
+].join("\n");
+
+function buildDefaultLocaleSettings(): MarqueeLocaleSettings {
+ return {
+ row1: DEFAULT_ROW_1,
+ row2: DEFAULT_ROW_2,
+ row3: DEFAULT_ROW_3,
+ row4: DEFAULT_ROW_4,
+ };
+}
+
+export function buildDefaultMarqueeSettings(): MarqueeSettings {
+ return {
+ locales: {
+ ar: buildDefaultLocaleSettings(),
+ en: buildDefaultLocaleSettings(),
+ de: buildDefaultLocaleSettings(),
+ },
+ };
+}
+
+function normalizeLocaleSettings(value: unknown, defaults: MarqueeLocaleSettings): MarqueeLocaleSettings {
+ const candidate = value && typeof value === "object" ? (value as Record) : {};
+
+ return {
+ row1: typeof candidate.row1 === "string" && candidate.row1.trim() ? candidate.row1.trim() : defaults.row1,
+ row2: typeof candidate.row2 === "string" && candidate.row2.trim() ? candidate.row2.trim() : defaults.row2,
+ row3: typeof candidate.row3 === "string" && candidate.row3.trim() ? candidate.row3.trim() : defaults.row3,
+ row4: typeof candidate.row4 === "string" && candidate.row4.trim() ? candidate.row4.trim() : defaults.row4,
+ };
+}
+
+export function parseMarqueeSettingsValue(rawValue?: string | null): MarqueeSettings {
+ const defaults = buildDefaultMarqueeSettings();
+
+ if (!rawValue) {
+ return defaults;
+ }
+
+ try {
+ const parsed = JSON.parse(rawValue) as { locales?: Record };
+ const locales = parsed && typeof parsed === "object" ? parsed.locales : undefined;
+
+ return {
+ locales: {
+ ar: normalizeLocaleSettings(locales?.ar, defaults.locales.ar),
+ en: normalizeLocaleSettings(locales?.en, defaults.locales.en),
+ de: normalizeLocaleSettings(locales?.de, defaults.locales.de),
+ },
+ };
+ } catch {
+ return defaults;
+ }
+}
+
+export function splitMarqueeRowItems(rawValue: string): string[] {
+ return rawValue
+ .split(/\r?\n|,/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+}
diff --git a/lib/root-navigation.ts b/lib/root-navigation.ts
index a9cbb79..8a764ca 100644
--- a/lib/root-navigation.ts
+++ b/lib/root-navigation.ts
@@ -8,6 +8,7 @@ import {
ShieldAlert,
SwatchBook,
Tags,
+ Type,
type LucideIcon,
} from "lucide-react";
@@ -18,6 +19,7 @@ type RootNavigationCopy = {
portfolio: string;
media: string;
siteSettings: string;
+ marquee?: string;
smtp?: string;
contactProtection?: string;
};
@@ -33,7 +35,7 @@ export type RootNavItem = {
export function getRootNavigation(
copy: RootNavigationCopy,
- active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp",
+ active: "overview" | "maintenance" | "ui-kit" | "portfolio" | "media" | "site-settings" | "smtp" | "marquee",
smtpChild?: "settings" | "contact-protection",
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
): RootNavItem[] {
@@ -68,6 +70,12 @@ export function getRootNavigation(
icon: Globe2,
active: active === "site-settings",
},
+ {
+ label: copy.marquee ?? "Marquee",
+ href: "/root/marquee",
+ icon: Type,
+ active: active === "marquee",
+ },
{
label: copy.smtp ?? "SMTP",
href: "/root/smtp",