Fix marquee behavior and unify marquee settings
This commit is contained in:
+16
-26
@@ -37,45 +37,35 @@ export async function saveMarqueeSettingsAction(formData: FormData) {
|
|||||||
ensureAdmin();
|
ensureAdmin();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const settings = {
|
const germanSettings = {
|
||||||
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(),
|
row1: String(formData.get("row1-de") ?? "").trim(),
|
||||||
row2: String(formData.get("row2-de") ?? "").trim(),
|
row2: String(formData.get("row2-de") ?? "").trim(),
|
||||||
row3: String(formData.get("row3-de") ?? "").trim(),
|
row3: String(formData.get("row3-de") ?? "").trim(),
|
||||||
row4: String(formData.get("row4-de") ?? "").trim(),
|
row4: String(formData.get("row4-de") ?? "").trim(),
|
||||||
},
|
};
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
locales: {
|
||||||
|
ar: { ...germanSettings },
|
||||||
|
en: { ...germanSettings },
|
||||||
|
de: germanSettings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const locale of routing.locales) {
|
if (!germanSettings.row1) {
|
||||||
if (!settings.locales[locale].row1) {
|
throw new Error("Row 1 fuer de ist erforderlich.");
|
||||||
throw new Error(`Row 1 fuer ${locale} ist erforderlich.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings.locales[locale].row2) {
|
if (!germanSettings.row2) {
|
||||||
throw new Error(`Row 2 fuer ${locale} ist erforderlich.`);
|
throw new Error("Row 2 fuer de ist erforderlich.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings.locales[locale].row3) {
|
if (!germanSettings.row3) {
|
||||||
throw new Error(`Row 3 fuer ${locale} ist erforderlich.`);
|
throw new Error("Row 3 fuer de ist erforderlich.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!settings.locales[locale].row4) {
|
if (!germanSettings.row4) {
|
||||||
throw new Error(`Row 4 fuer ${locale} ist erforderlich.`);
|
throw new Error("Row 4 fuer de ist erforderlich.");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateMarqueeSettings(settings);
|
await updateMarqueeSettings(settings);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
|||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Container } from "@/components/layout/container";
|
import { Container } from "@/components/layout/container";
|
||||||
import { RetroLedMarquee } from "@/components/layout/retro-led-marquee";
|
|
||||||
import { getLocalizedPath } from "@/lib/locale";
|
import { getLocalizedPath } from "@/lib/locale";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -21,7 +20,6 @@ export function SiteFooter({ isAdmin = false }: SiteFooterProps) {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const tNav = useTranslations("navigation");
|
const tNav = useTranslations("navigation");
|
||||||
const tFooter = useTranslations("footer");
|
const tFooter = useTranslations("footer");
|
||||||
const ledText = "MADE IN GERMANY BY MOH";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-border bg-background">
|
<footer className="border-t border-border bg-background">
|
||||||
@@ -46,7 +44,6 @@ export function SiteFooter({ isAdmin = false }: SiteFooterProps) {
|
|||||||
<p className="text-xs text-muted-foreground/80">
|
<p className="text-xs text-muted-foreground/80">
|
||||||
{tFooter("copyright", { year: new Date().getFullYear() })}
|
{tFooter("copyright", { year: new Date().getFullYear() })}
|
||||||
</p>
|
</p>
|
||||||
<RetroLedMarquee text={ledText} />
|
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { motion, useAnimationFrame, useMotionValue, useReducedMotion } from "framer-motion";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -86,33 +87,80 @@ const DEFAULT_ROWS: MarqueeRow[] = [
|
|||||||
|
|
||||||
function MarqueeRowTrack({ items, direction, duration = 24 }: MarqueeRow) {
|
function MarqueeRowTrack({ items, direction, duration = 24 }: MarqueeRow) {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
const segmentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [segmentWidth, setSegmentWidth] = useState(0);
|
||||||
|
const [copyCount, setCopyCount] = useState(3);
|
||||||
|
const x = useMotionValue(0);
|
||||||
|
const isReady = shouldReduceMotion || segmentWidth > 0;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const segment = segmentRef.current;
|
||||||
|
|
||||||
|
if (!viewport || !segment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const nextSegmentWidth = segment.scrollWidth;
|
||||||
|
const viewportWidth = viewport.clientWidth;
|
||||||
|
|
||||||
|
if (!nextSegmentWidth || !viewportWidth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSegmentWidth(nextSegmentWidth);
|
||||||
|
setCopyCount(Math.max(3, Math.ceil(viewportWidth / nextSegmentWidth) + 2));
|
||||||
|
x.set(direction === "right" ? -nextSegmentWidth : 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
measure();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(viewport);
|
||||||
|
observer.observe(segment);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [direction, items, x]);
|
||||||
|
|
||||||
|
useAnimationFrame((_, delta) => {
|
||||||
|
if (shouldReduceMotion || segmentWidth === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distancePerMs = segmentWidth / (duration * 1000);
|
||||||
|
const offset = distancePerMs * delta;
|
||||||
|
const current = x.get();
|
||||||
|
|
||||||
|
if (direction === "right") {
|
||||||
|
const next = current + offset;
|
||||||
|
x.set(next >= 0 ? next - segmentWidth : next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = current - offset;
|
||||||
|
x.set(next <= -segmentWidth ? next + segmentWidth : next);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden leading-[0.92]">
|
<div ref={viewportRef} className="overflow-hidden leading-[0.92]">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex w-max min-w-full flex-nowrap"
|
dir="ltr"
|
||||||
animate={
|
className={cn("flex w-max flex-nowrap", !isReady && "opacity-0")}
|
||||||
shouldReduceMotion
|
style={{ x }}
|
||||||
? { 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) => (
|
{Array.from({ length: copyCount }).map((_, copyIndex) => (
|
||||||
<div
|
<div
|
||||||
key={`${direction}-${copyIndex}`}
|
key={`${direction}-${copyIndex}`}
|
||||||
|
ref={copyIndex === 0 ? segmentRef : undefined}
|
||||||
className="flex shrink-0 items-center gap-3 pr-3 sm:gap-4 sm:pr-4 lg:gap-5 lg:pr-5"
|
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}
|
aria-hidden={copyIndex > 0}
|
||||||
>
|
>
|
||||||
{items.map((item, itemIndex) => (
|
{items.map((item, itemIndex) => (
|
||||||
<div
|
<div
|
||||||
@@ -156,7 +204,7 @@ export function StackedMarqueeSection({
|
|||||||
>
|
>
|
||||||
<div className="relative flex flex-col gap-0 sm:gap-0 lg:gap-0">
|
<div className="relative flex flex-col gap-0 sm:gap-0 lg:gap-0">
|
||||||
{rows.map((row, index) => (
|
{rows.map((row, index) => (
|
||||||
<div key={`${row.direction}-${index}`} className={index === 0 ? "" : "-mt-0.5 sm:-mt-1"}>
|
<div key={`${row.direction}-${index}`}>
|
||||||
<MarqueeRowTrack
|
<MarqueeRowTrack
|
||||||
items={row.items}
|
items={row.items}
|
||||||
direction={row.direction}
|
direction={row.direction}
|
||||||
|
|||||||
@@ -9,12 +9,6 @@ type MarqueeSettingsFormProps = {
|
|||||||
initialSettings: MarqueeSettings;
|
initialSettings: MarqueeSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
const localeMeta = [
|
|
||||||
{ key: "de", label: "Deutsch" },
|
|
||||||
{ key: "en", label: "English" },
|
|
||||||
{ key: "ar", label: "Arabic" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const rowMeta = [
|
const rowMeta = [
|
||||||
{ key: "row1", label: "Row 1" },
|
{ key: "row1", label: "Row 1" },
|
||||||
{ key: "row2", label: "Row 2" },
|
{ key: "row2", label: "Row 2" },
|
||||||
@@ -31,31 +25,29 @@ export function MarqueeSettingsForm({
|
|||||||
<AppCard>
|
<AppCard>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Marquee Rows</CardTitle>
|
<CardTitle>Marquee Rows</CardTitle>
|
||||||
<CardDescription>Ein Feld pro Zeile. Ein Begriff pro Zeile oder mit Komma getrennt.</CardDescription>
|
<CardDescription>Nur Deutsch bearbeiten. Die Werte werden fuer alle Sprachen uebernommen.</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-6 xl:grid-cols-3">
|
<CardContent>
|
||||||
{localeMeta.map((locale) => (
|
<div className="space-y-4 rounded-nested border border-border/70 bg-background/70 p-4">
|
||||||
<div key={locale.key} className="space-y-4 rounded-nested border border-border/70 bg-background/70 p-4">
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground">{locale.label}</h3>
|
<h3 className="text-sm font-semibold text-foreground">Deutsch</h3>
|
||||||
<p className="text-xs text-muted-foreground">Vier Marquee Zeilen fuer diese Sprache.</p>
|
<p className="text-xs text-muted-foreground">Vier Marquee Zeilen. Diese Werte gelten fuer alle Sprachen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{rowMeta.map((row) => (
|
{rowMeta.map((row) => (
|
||||||
<div key={`${locale.key}-${row.key}`} className="space-y-2">
|
<div key={`de-${row.key}`} className="space-y-2">
|
||||||
<Label htmlFor={`${row.key}-${locale.key}`}>{row.label}</Label>
|
<Label htmlFor={`${row.key}-de`}>{row.label}</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id={`${row.key}-${locale.key}`}
|
id={`${row.key}-de`}
|
||||||
name={`${row.key}-${locale.key}`}
|
name={`${row.key}-de`}
|
||||||
defaultValue={initialSettings.locales[locale.key][row.key]}
|
defaultValue={initialSettings.locales.de[row.key]}
|
||||||
className="min-h-[180px] resize-y"
|
className="min-h-[180px] resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+6
-2
@@ -39,6 +39,7 @@ export {
|
|||||||
buildDefaultMarqueeSettings,
|
buildDefaultMarqueeSettings,
|
||||||
parseMarqueeSettingsValue,
|
parseMarqueeSettingsValue,
|
||||||
splitMarqueeRowItems,
|
splitMarqueeRowItems,
|
||||||
|
syncMarqueeSettingsToGermanSource,
|
||||||
type MarqueeLocaleSettings,
|
type MarqueeLocaleSettings,
|
||||||
type MarqueeSettings,
|
type MarqueeSettings,
|
||||||
} from "./marquee-settings";
|
} from "./marquee-settings";
|
||||||
@@ -80,6 +81,7 @@ import {
|
|||||||
MARQUEE_SETTINGS_KEY,
|
MARQUEE_SETTINGS_KEY,
|
||||||
buildDefaultMarqueeSettings,
|
buildDefaultMarqueeSettings,
|
||||||
parseMarqueeSettingsValue,
|
parseMarqueeSettingsValue,
|
||||||
|
syncMarqueeSettingsToGermanSource,
|
||||||
type MarqueeSettings,
|
type MarqueeSettings,
|
||||||
} from "./marquee-settings";
|
} from "./marquee-settings";
|
||||||
|
|
||||||
@@ -231,14 +233,16 @@ export async function getMarqueeSettings(): Promise<MarqueeSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> {
|
export async function updateMarqueeSettings(settings: MarqueeSettings): Promise<void> {
|
||||||
|
const normalizedSettings = syncMarqueeSettingsToGermanSource(settings);
|
||||||
|
|
||||||
await prisma.appConfig.upsert({
|
await prisma.appConfig.upsert({
|
||||||
where: { key: MARQUEE_SETTINGS_KEY },
|
where: { key: MARQUEE_SETTINGS_KEY },
|
||||||
update: {
|
update: {
|
||||||
value: JSON.stringify(settings),
|
value: JSON.stringify(normalizedSettings),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
key: MARQUEE_SETTINGS_KEY,
|
key: MARQUEE_SETTINGS_KEY,
|
||||||
value: JSON.stringify(settings),
|
value: JSON.stringify(normalizedSettings),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -97,26 +97,38 @@ function normalizeLocaleSettings(value: unknown, defaults: MarqueeLocaleSettings
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function syncMarqueeSettingsToGermanSource(settings: MarqueeSettings): MarqueeSettings {
|
||||||
|
const source = settings.locales.de;
|
||||||
|
|
||||||
|
return {
|
||||||
|
locales: {
|
||||||
|
de: { ...source },
|
||||||
|
en: { ...source },
|
||||||
|
ar: { ...source },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function parseMarqueeSettingsValue(rawValue?: string | null): MarqueeSettings {
|
export function parseMarqueeSettingsValue(rawValue?: string | null): MarqueeSettings {
|
||||||
const defaults = buildDefaultMarqueeSettings();
|
const defaults = buildDefaultMarqueeSettings();
|
||||||
|
|
||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
return defaults;
|
return syncMarqueeSettingsToGermanSource(defaults);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(rawValue) as { locales?: Record<string, unknown> };
|
const parsed = JSON.parse(rawValue) as { locales?: Record<string, unknown> };
|
||||||
const locales = parsed && typeof parsed === "object" ? parsed.locales : undefined;
|
const locales = parsed && typeof parsed === "object" ? parsed.locales : undefined;
|
||||||
|
|
||||||
return {
|
return syncMarqueeSettingsToGermanSource({
|
||||||
locales: {
|
locales: {
|
||||||
ar: normalizeLocaleSettings(locales?.ar, defaults.locales.ar),
|
ar: normalizeLocaleSettings(locales?.ar, defaults.locales.ar),
|
||||||
en: normalizeLocaleSettings(locales?.en, defaults.locales.en),
|
en: normalizeLocaleSettings(locales?.en, defaults.locales.en),
|
||||||
de: normalizeLocaleSettings(locales?.de, defaults.locales.de),
|
de: normalizeLocaleSettings(locales?.de, defaults.locales.de),
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return defaults;
|
return syncMarqueeSettingsToGermanSource(defaults);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user