This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { LocaleToggle } from "@/components/layout/locale-toggle";
|
||||
import { SoundToggle } from "@/components/sound-toggle";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -21,6 +22,12 @@ export function FloatingPreferences({
|
||||
<div className={cn("fixed inset-x-0 top-0 z-50 px-4 pt-4 sm:px-6", className)}>
|
||||
<div className="mx-auto flex w-full justify-center">
|
||||
<div className="inline-flex items-center justify-center gap-1 rounded-pill border border-border/70 bg-background/80 p-1 shadow-panel backdrop-blur-chrome">
|
||||
<SoundToggle
|
||||
ariaLabel={t("soundMute")}
|
||||
mutedAriaLabel={t("soundUnmute")}
|
||||
variant="ghost"
|
||||
className="h-9 w-9 rounded-pill border border-transparent bg-transparent text-foreground/80 hover:bg-accent hover:text-foreground"
|
||||
/>
|
||||
<ThemeToggle
|
||||
ariaLabel={t("themeToggle")}
|
||||
variant="ghost"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useState } from "react";
|
||||
import { Container } from "@/components/layout/container";
|
||||
import { LocaleToggle } from "@/components/layout/locale-toggle";
|
||||
import { SiteLogo } from "@/components/layout/site-logo";
|
||||
import { SoundToggle } from "@/components/sound-toggle";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
|
||||
@@ -23,6 +24,41 @@ const navItems = [
|
||||
{ key: "contact", path: "/contact" },
|
||||
];
|
||||
|
||||
const brandTitleCharacters = [
|
||||
...Array.from("MOH").map((character) => ({ character, tone: "base" as const })),
|
||||
{ character: " ", tone: "space" as const },
|
||||
...Array.from("FARAWATI").map((character) => ({ character, tone: "accent" as const })),
|
||||
];
|
||||
const brandShellVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.035,
|
||||
delayChildren: 0.18,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
const brandCharacterVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 18,
|
||||
rotateX: -80,
|
||||
filter: "blur(8px)",
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotateX: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
type: "spring" as const,
|
||||
stiffness: 280,
|
||||
damping: 20,
|
||||
mass: 0.8,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
type SiteHeaderProps = {
|
||||
isAdmin?: boolean;
|
||||
lightLogoUrl?: string | null;
|
||||
@@ -109,15 +145,52 @@ export function SiteHeader({
|
||||
<div className="col-start-1 flex items-center justify-self-start">
|
||||
<Link
|
||||
href={getLocalizedPath(locale)}
|
||||
className="inline-flex items-center rounded-pill px-1 py-1 sm:py-1.5 lg:py-2"
|
||||
className="inline-flex items-center gap-2.5 rounded-pill px-1 py-1 sm:gap-3 lg:py-2"
|
||||
>
|
||||
<SiteLogo
|
||||
lightLogoUrl={lightLogoUrl}
|
||||
darkLogoUrl={darkLogoUrl}
|
||||
alt="mohfarawati"
|
||||
priority
|
||||
className="h-7 opacity-88 sm:h-8 lg:h-9"
|
||||
/>
|
||||
<motion.span
|
||||
initial={{ opacity: 0, x: -18, scale: 0.82, rotate: -14, filter: "blur(10px)" }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1, rotate: 0, filter: "blur(0px)" }}
|
||||
transition={{ type: "spring", stiffness: 240, damping: 20, mass: 0.9 }}
|
||||
className="relative inline-flex items-center justify-center [transform-style:preserve-3d]"
|
||||
>
|
||||
<SiteLogo
|
||||
lightLogoUrl={lightLogoUrl}
|
||||
darkLogoUrl={darkLogoUrl}
|
||||
alt="mohfarawati"
|
||||
priority
|
||||
className="relative h-10 opacity-95"
|
||||
/>
|
||||
</motion.span>
|
||||
<motion.span
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={brandShellVariants}
|
||||
className="flex flex-col justify-center"
|
||||
>
|
||||
<span className="flex flex-wrap items-center text-[1.28rem] font-black leading-[1.2]">
|
||||
{brandTitleCharacters.map(({ character, tone }, index) => (
|
||||
<motion.span
|
||||
key={`${character}-${index}`}
|
||||
variants={brandCharacterVariants}
|
||||
className={cn(
|
||||
character === " " ? "mr-[0.11em] w-[0.11em]" : "mr-[0.005em] inline-block",
|
||||
tone === "base" ? "text-[hsl(var(--hero-ink))] dark:text-foreground" : "",
|
||||
tone === "accent" ? "text-primary" : "",
|
||||
)}
|
||||
>
|
||||
{character === " " ? "\u00A0" : character}
|
||||
</motion.span>
|
||||
))}
|
||||
</span>
|
||||
<motion.span
|
||||
initial={{ opacity: 0, y: 20, clipPath: "inset(0 0 100% 0)", filter: "blur(10px)" }}
|
||||
animate={{ opacity: 1, y: 0, clipPath: "inset(0 0 0% 0)", filter: "blur(0px)" }}
|
||||
transition={{ duration: 0.7, delay: 0.34, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="-mt-0.5 text-[0.65rem] font-light leading-[1.2] tracking-[0.008em] text-foreground/78"
|
||||
>
|
||||
Webdesigner Bremen | Freelancer
|
||||
</motion.span>
|
||||
</motion.span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -146,6 +219,12 @@ export function SiteHeader({
|
||||
locale={locale}
|
||||
className={desktopControlButtonClassName}
|
||||
/>
|
||||
<SoundToggle
|
||||
ariaLabel={t("soundMute")}
|
||||
mutedAriaLabel={t("soundUnmute")}
|
||||
variant="ghost"
|
||||
className={desktopControlButtonClassName}
|
||||
/>
|
||||
<ThemeToggle
|
||||
ariaLabel={t("themeToggle")}
|
||||
variant="ghost"
|
||||
@@ -154,7 +233,7 @@ export function SiteHeader({
|
||||
{isAdmin ? (
|
||||
<Button asChild variant="ghost" size="icon" className={desktopControlButtonClassName}>
|
||||
<Link href="/root" aria-label={t("root")}>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
<LayoutDashboard className="h-4 w-4 transition-transform duration-300 ease-out hover:scale-110" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -263,6 +342,12 @@ export function SiteHeader({
|
||||
className={mobileControlButtonClassName}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<SoundToggle
|
||||
ariaLabel={t("soundMute")}
|
||||
mutedAriaLabel={t("soundUnmute")}
|
||||
variant="ghost"
|
||||
className={mobileControlButtonClassName}
|
||||
/>
|
||||
<ThemeToggle
|
||||
ariaLabel={t("themeToggle")}
|
||||
variant="ghost"
|
||||
@@ -271,7 +356,7 @@ export function SiteHeader({
|
||||
{isAdmin ? (
|
||||
<Button asChild variant="ghost" size="icon" className={mobileControlButtonClassName}>
|
||||
<Link href="/root" aria-label={t("root")} onClick={() => setIsOpen(false)}>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
<LayoutDashboard className="h-4 w-4 transition-transform duration-300 ease-out hover:scale-110" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -36,9 +36,12 @@ export function SiteLogo({
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className="block"
|
||||
whileHover={{ scale: 1.04, y: -1 }}
|
||||
transition={{ duration: 0.28, ease: "easeOut" }}
|
||||
className="block [transform-style:preserve-3d]"
|
||||
initial={{ opacity: 0, x: -42, scale: 0.88, rotate: -360 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1, rotate: 0 }}
|
||||
whileHover={{ scale: 1.05, y: -2, rotate: -2, filter: "drop-shadow(0 16px 28px rgba(15,23,42,0.14))" }}
|
||||
whileTap={{ scale: 0.9, rotate: 6, y: 1, filter: "drop-shadow(0 8px 18px rgba(15,23,42,0.18))" }}
|
||||
transition={{ x: { duration: 0.78, ease: [0.22, 1, 0.36, 1] }, opacity: { duration: 0.46 }, rotate: { duration: 0.56, ease: "easeOut" }, scale: { duration: 0.72, ease: [0.22, 1, 0.36, 1] } }}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DashboardLayout } from "@/components/dashboard/dashboard-layout";
|
||||
import { MotionFade } from "@/components/motion-fade";
|
||||
import { FormSaveButton } from "@/components/root/form-save-button";
|
||||
import { SidebarMaintenanceControl } from "@/components/root/sidebar-maintenance-control";
|
||||
import { SoundToggle } from "@/components/sound-toggle";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getMaintenanceMode, getSiteSettingsMediaBindings } from "@/lib/app-config";
|
||||
@@ -119,6 +120,7 @@ export async function RootDashboardShell({
|
||||
label={saveButtonLabel ?? "Speichern"}
|
||||
reloadDocumentOnSuccess={reloadDocumentOnSave}
|
||||
/>
|
||||
<SoundToggle ariaLabel="Mute sounds" mutedAriaLabel="Unmute sounds" />
|
||||
<ThemeToggle ariaLabel="Theme wechseln" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
type SoundContextValue = {
|
||||
isMuted: boolean;
|
||||
toggleMuted: () => void;
|
||||
playSound: (src: string) => void;
|
||||
};
|
||||
|
||||
const SOUND_MUTED_STORAGE_KEY = "mohfarawati-sound-muted";
|
||||
|
||||
const SoundContext = createContext<SoundContextValue | null>(null);
|
||||
|
||||
export function SoundProvider({ children }: { children: ReactNode }) {
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
const storedValue = window.localStorage.getItem(SOUND_MUTED_STORAGE_KEY);
|
||||
|
||||
if (storedValue === "true") {
|
||||
setIsMuted(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(SOUND_MUTED_STORAGE_KEY, String(isMuted));
|
||||
}, [isMuted, mounted]);
|
||||
|
||||
const value = useMemo<SoundContextValue>(
|
||||
() => ({
|
||||
isMuted,
|
||||
toggleMuted: () => {
|
||||
setIsMuted((currentValue) => !currentValue);
|
||||
},
|
||||
playSound: (src: string) => {
|
||||
if (!mounted || isMuted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = new Audio(src);
|
||||
audio.preload = "auto";
|
||||
void audio.play().catch(() => {});
|
||||
},
|
||||
}),
|
||||
[isMuted, mounted],
|
||||
);
|
||||
|
||||
return <SoundContext.Provider value={value}>{children}</SoundContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSound() {
|
||||
const context = useContext(SoundContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useSound must be used within SoundProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Volume2, VolumeX } from "lucide-react";
|
||||
|
||||
import { useSound } from "@/components/sound-provider";
|
||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SoundToggleProps = {
|
||||
ariaLabel?: string;
|
||||
mutedAriaLabel?: string;
|
||||
variant?: ButtonProps["variant"];
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
export function SoundToggle({
|
||||
ariaLabel = "Mute sounds",
|
||||
mutedAriaLabel = "Unmute sounds",
|
||||
variant = "outline",
|
||||
className,
|
||||
iconClassName,
|
||||
}: SoundToggleProps) {
|
||||
const { isMuted, toggleMuted } = useSound();
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size="icon"
|
||||
onClick={toggleMuted}
|
||||
aria-label={isMuted ? mutedAriaLabel : ariaLabel}
|
||||
aria-pressed={isMuted}
|
||||
className={cn("group cursor-pointer", className)}
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX
|
||||
strokeWidth={1.6}
|
||||
className={cn("h-[1.125rem] w-[1.125rem] transition-transform duration-300 ease-out group-hover:scale-110", iconClassName)}
|
||||
/>
|
||||
) : (
|
||||
<Volume2
|
||||
strokeWidth={1.6}
|
||||
className={cn("h-[1.125rem] w-[1.125rem] transition-transform duration-300 ease-out group-hover:scale-110", iconClassName)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useSound } from "@/components/sound-provider";
|
||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -23,6 +24,7 @@ export function ThemeToggle({
|
||||
iconClassName,
|
||||
}: ThemeToggleProps) {
|
||||
const { setTheme, theme, resolvedTheme } = useTheme();
|
||||
const { playSound } = useSound();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +40,7 @@ export function ThemeToggle({
|
||||
aria-label={ariaLabel}
|
||||
className={cn("group cursor-pointer", label ? "justify-start" : undefined, className)}
|
||||
>
|
||||
<Moon className={cn("h-4 w-4 transition-transform duration-700 ease-out group-hover:scale-110 group-hover:rotate-[360deg]", iconClassName)} />
|
||||
<Moon className={cn("h-4 w-4 transition-transform duration-300 ease-out group-hover:scale-110", iconClassName)} />
|
||||
{label ? <span>{label}</span> : null}
|
||||
</Button>
|
||||
);
|
||||
@@ -47,19 +49,26 @@ export function ThemeToggle({
|
||||
const activeTheme = theme === "system" ? resolvedTheme : theme;
|
||||
const isDark = activeTheme === "dark";
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
const nextTheme = isDark ? "light" : "dark";
|
||||
|
||||
setTheme(nextTheme);
|
||||
playSound(nextTheme === "dark" ? "/audio/dark.mp3" : "/audio/light.mp3");
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={label ? "sm" : "icon"}
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
onClick={handleThemeToggle}
|
||||
aria-label={ariaLabel}
|
||||
className={cn("group cursor-pointer", label ? "justify-start" : undefined, className)}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className={cn("h-4 w-4 transition-transform duration-700 ease-out group-hover:scale-110 group-hover:rotate-[360deg]", iconClassName)} />
|
||||
<Sun className={cn("h-4 w-4 transition-transform duration-300 ease-out group-hover:scale-110", iconClassName)} />
|
||||
) : (
|
||||
<Moon className={cn("h-4 w-4 transition-transform duration-700 ease-out group-hover:scale-110 group-hover:rotate-[360deg]", iconClassName)} />
|
||||
<Moon className={cn("h-4 w-4 transition-transform duration-300 ease-out group-hover:scale-110", iconClassName)} />
|
||||
)}
|
||||
{label ? <span>{label}</span> : null}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user