Add editable localized marquee settings
This commit is contained in:
@@ -14,8 +14,9 @@ import { getTranslations } from "next-intl/server";
|
|||||||
|
|
||||||
import { Container } from "@/components/layout/container";
|
import { Container } from "@/components/layout/container";
|
||||||
import { HomeHero } from "@/components/layout/home-hero";
|
import { HomeHero } from "@/components/layout/home-hero";
|
||||||
|
import { StackedMarqueeSection, type MarqueeRow } from "@/components/layout/stacked-marquee-section";
|
||||||
import { MotionFade } from "@/components/motion-fade";
|
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 { buildLocalizedMetadata } from "@/lib/metadata";
|
||||||
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";
|
||||||
@@ -60,7 +61,10 @@ export async function generateMetadata({
|
|||||||
|
|
||||||
export default async function HomePage({ params: { locale } }: HomePageProps) {
|
export default async function HomePage({ params: { locale } }: HomePageProps) {
|
||||||
const localeKey = resolveLocale(locale);
|
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 = [
|
const serviceItems = [
|
||||||
t("serviceBrand"),
|
t("serviceBrand"),
|
||||||
t("serviceWebsite"),
|
t("serviceWebsite"),
|
||||||
@@ -70,6 +74,13 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
|
|||||||
t("serviceTracking"),
|
t("serviceTracking"),
|
||||||
t("serviceSystem"),
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -141,6 +152,8 @@ export default async function HomePage({ params: { locale } }: HomePageProps) {
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
</MotionFade>
|
</MotionFade>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
<StackedMarqueeSection rows={marqueeRows} className="relative z-10 mt-2 pb-10 sm:pb-12 lg:pb-14" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<RootDashboardShell
|
||||||
|
copy={copy}
|
||||||
|
active="marquee"
|
||||||
|
logoutAction={logoutAction}
|
||||||
|
headerTitle={copy.title}
|
||||||
|
headerDescription={copy.subtitle}
|
||||||
|
saveFormId="marquee-settings-form"
|
||||||
|
reloadDocumentOnSave
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{searchParams?.success ? (
|
||||||
|
<MotionFade delay={0.08}>
|
||||||
|
<FlashMessage type="success" message={searchParams.success} />
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{searchParams?.error ? (
|
||||||
|
<MotionFade delay={0.1}>
|
||||||
|
<FlashMessage type="error" message={searchParams.error} />
|
||||||
|
</MotionFade>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<MotionFade delay={0.14}>
|
||||||
|
<MarqueeSettingsForm
|
||||||
|
action={saveMarqueeSettingsAction}
|
||||||
|
initialSettings={marqueeSettings}
|
||||||
|
/>
|
||||||
|
</MotionFade>
|
||||||
|
</div>
|
||||||
|
</RootDashboardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="overflow-hidden leading-[0.92]">
|
||||||
|
<motion.div
|
||||||
|
className="flex w-max min-w-full flex-nowrap"
|
||||||
|
animate={
|
||||||
|
shouldReduceMotion
|
||||||
|
? { x: 0 }
|
||||||
|
: {
|
||||||
|
x: direction === "left" ? ["0%", "-50%"] : ["-50%", "0%"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transition={
|
||||||
|
shouldReduceMotion
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
duration,
|
||||||
|
ease: "linear",
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 2 }).map((_, copyIndex) => (
|
||||||
|
<div
|
||||||
|
key={`${direction}-${copyIndex}`}
|
||||||
|
className="flex shrink-0 items-center gap-3 pr-3 sm:gap-4 sm:pr-4 lg:gap-5 lg:pr-5"
|
||||||
|
aria-hidden={copyIndex === 1}
|
||||||
|
>
|
||||||
|
{items.map((item, itemIndex) => (
|
||||||
|
<div
|
||||||
|
key={`${copyIndex}-${item}`}
|
||||||
|
className="flex shrink-0 items-center gap-3 sm:gap-4 lg:gap-5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 whitespace-nowrap text-[clamp(2.05rem,6vw,4.8rem)] uppercase leading-[0.92] tracking-[-0.01em] text-[hsl(var(--surface-3))] dark:text-[hsl(var(--surface-3))] [text-shadow:none]",
|
||||||
|
itemIndex % 2 === 0 ? "font-black" : "font-light",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[clamp(1.85rem,5vw,4rem)] font-light leading-[0.92] tracking-[-0.01em] text-[hsl(var(--surface-3))] dark:text-[hsl(var(--surface-3))] [text-shadow:none]"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StackedMarqueeSection({
|
||||||
|
className,
|
||||||
|
rows = DEFAULT_ROWS,
|
||||||
|
}: StackedMarqueeSectionProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Technology marquee"
|
||||||
|
className={cn(
|
||||||
|
"relative isolate overflow-hidden bg-transparent py-3 sm:py-4 lg:py-5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="relative flex flex-col gap-0 sm:gap-0 lg:gap-0">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<div key={`${row.direction}-${index}`} className={index === 0 ? "" : "-mt-0.5 sm:-mt-1"}>
|
||||||
|
<MarqueeRowTrack
|
||||||
|
items={row.items}
|
||||||
|
direction={row.direction}
|
||||||
|
duration={row.duration}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
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 (
|
||||||
|
<form id="marquee-settings-form" action={action} className="space-y-6">
|
||||||
|
<AppCard>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Marquee Rows</CardTitle>
|
||||||
|
<CardDescription>Ein Feld pro Zeile. Ein Begriff pro Zeile oder mit Komma getrennt.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-6 xl:grid-cols-3">
|
||||||
|
{localeMeta.map((locale) => (
|
||||||
|
<div key={locale.key} className="space-y-4 rounded-nested border border-border/70 bg-background/70 p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{locale.label}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">Vier Marquee Zeilen fuer diese Sprache.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{rowMeta.map((row) => (
|
||||||
|
<div key={`${locale.key}-${row.key}`} className="space-y-2">
|
||||||
|
<Label htmlFor={`${row.key}-${locale.key}`}>{row.label}</Label>
|
||||||
|
<Textarea
|
||||||
|
id={`${row.key}-${locale.key}`}
|
||||||
|
name={`${row.key}-${locale.key}`}
|
||||||
|
defaultValue={initialSettings.locales[locale.key][row.key]}
|
||||||
|
className="min-h-[180px] resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</AppCard>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Tags,
|
Tags,
|
||||||
|
Type,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ type RootDashboardCopy = {
|
|||||||
portfolio: string;
|
portfolio: string;
|
||||||
media: string;
|
media: string;
|
||||||
siteSettings: string;
|
siteSettings: string;
|
||||||
|
marquee?: string;
|
||||||
smtp?: string;
|
smtp?: string;
|
||||||
contactProtection?: string;
|
contactProtection?: string;
|
||||||
logout: string;
|
logout: string;
|
||||||
@@ -43,7 +45,7 @@ type RootDashboardCopy = {
|
|||||||
|
|
||||||
type RootDashboardShellProps = {
|
type RootDashboardShellProps = {
|
||||||
copy: RootDashboardCopy;
|
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";
|
smtpChild?: "settings" | "contact-protection";
|
||||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories";
|
||||||
logoutAction: () => Promise<void>;
|
logoutAction: () => Promise<void>;
|
||||||
@@ -92,6 +94,8 @@ export async function RootDashboardShell({
|
|||||||
? SwatchBook
|
? SwatchBook
|
||||||
: active === "site-settings"
|
: active === "site-settings"
|
||||||
? Globe2
|
? Globe2
|
||||||
|
: active === "marquee"
|
||||||
|
? Type
|
||||||
: active === "smtp"
|
: active === "smtp"
|
||||||
? ShieldAlert
|
? ShieldAlert
|
||||||
: active === "media"
|
: active === "media"
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ export {
|
|||||||
type ContactProtectionFormValues,
|
type ContactProtectionFormValues,
|
||||||
type PublicContactProtectionSettings,
|
type PublicContactProtectionSettings,
|
||||||
} from "./contact-protection";
|
} from "./contact-protection";
|
||||||
|
export {
|
||||||
|
MARQUEE_SETTINGS_KEY,
|
||||||
|
buildDefaultMarqueeSettings,
|
||||||
|
parseMarqueeSettingsValue,
|
||||||
|
splitMarqueeRowItems,
|
||||||
|
type MarqueeLocaleSettings,
|
||||||
|
type MarqueeSettings,
|
||||||
|
} from "./marquee-settings";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SITE_NAME,
|
DEFAULT_SITE_NAME,
|
||||||
SITE_NAME_KEY,
|
SITE_NAME_KEY,
|
||||||
@@ -68,6 +76,12 @@ import {
|
|||||||
type ContactProtectionFormValues,
|
type ContactProtectionFormValues,
|
||||||
type PublicContactProtectionSettings,
|
type PublicContactProtectionSettings,
|
||||||
} from "./contact-protection";
|
} from "./contact-protection";
|
||||||
|
import {
|
||||||
|
MARQUEE_SETTINGS_KEY,
|
||||||
|
buildDefaultMarqueeSettings,
|
||||||
|
parseMarqueeSettingsValue,
|
||||||
|
type MarqueeSettings,
|
||||||
|
} from "./marquee-settings";
|
||||||
|
|
||||||
export async function getMaintenanceMode(): Promise<boolean> {
|
export async function getMaintenanceMode(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
@@ -203,6 +217,32 @@ export async function updateContactProtectionSettings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMarqueeSettings(): Promise<MarqueeSettings> {
|
||||||
|
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<void> {
|
||||||
|
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<SiteSettingsMediaBindings> {
|
export async function getSiteSettingsMediaBindings(): Promise<SiteSettingsMediaBindings> {
|
||||||
try {
|
try {
|
||||||
const usages = await prisma.mediaUsage.findMany({
|
const usages = await prisma.mediaUsage.findMany({
|
||||||
|
|||||||
@@ -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<string, unknown>) : {};
|
||||||
|
|
||||||
|
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<string, unknown> };
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
SwatchBook,
|
SwatchBook,
|
||||||
Tags,
|
Tags,
|
||||||
|
Type,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ type RootNavigationCopy = {
|
|||||||
portfolio: string;
|
portfolio: string;
|
||||||
media: string;
|
media: string;
|
||||||
siteSettings: string;
|
siteSettings: string;
|
||||||
|
marquee?: string;
|
||||||
smtp?: string;
|
smtp?: string;
|
||||||
contactProtection?: string;
|
contactProtection?: string;
|
||||||
};
|
};
|
||||||
@@ -33,7 +35,7 @@ export type RootNavItem = {
|
|||||||
|
|
||||||
export function getRootNavigation(
|
export function getRootNavigation(
|
||||||
copy: RootNavigationCopy,
|
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",
|
smtpChild?: "settings" | "contact-protection",
|
||||||
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
portfolioChild?: "overview" | "projects" | "new-project" | "categories",
|
||||||
): RootNavItem[] {
|
): RootNavItem[] {
|
||||||
@@ -68,6 +70,12 @@ export function getRootNavigation(
|
|||||||
icon: Globe2,
|
icon: Globe2,
|
||||||
active: active === "site-settings",
|
active: active === "site-settings",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: copy.marquee ?? "Marquee",
|
||||||
|
href: "/root/marquee",
|
||||||
|
icon: Type,
|
||||||
|
active: active === "marquee",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: copy.smtp ?? "SMTP",
|
label: copy.smtp ?? "SMTP",
|
||||||
href: "/root/smtp",
|
href: "/root/smtp",
|
||||||
|
|||||||
Reference in New Issue
Block a user