IMPROVED - Use swappable image icons and move controls to a corner dock
- Dock icons are now real SVG files under public/dock/ (macOS squircle look, active variants), so they can be replaced with custom art later. - Full-bleed icons (DockIcon padding 0), fixed logo src to stop the refresh flash. - Remove the top menu bar entirely; relocate theme/language/sound (+admin) into a corner control dock that expands on click.
This commit is contained in:
@@ -1,119 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
export type DockSymbol = "home" | "about" | "portfolio" | "products" | "contact";
|
||||
|
||||
/**
|
||||
* macOS Big Sur–style squircle app icon, drawn entirely in SVG so it stays
|
||||
* crisp at every magnification step. Inactive icons use a neutral graphite
|
||||
* gradient; the active icon lights up in the brand coral.
|
||||
*/
|
||||
export function DockAppIcon({
|
||||
symbol,
|
||||
active = false,
|
||||
disabled = false,
|
||||
}: {
|
||||
symbol: DockSymbol;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const uid = useId().replace(/[:]/g, "");
|
||||
const bg = `bg-${uid}`;
|
||||
const gloss = `gloss-${uid}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="h-full w-full"
|
||||
role="presentation"
|
||||
style={disabled ? { opacity: 0.55 } : undefined}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={bg} x1="0" y1="0" x2="0" y2="1">
|
||||
{active ? (
|
||||
<>
|
||||
<stop offset="0" stopColor="#ff8a5f" />
|
||||
<stop offset="1" stopColor="#e5431f" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<stop offset="0" stopColor="#454b57" />
|
||||
<stop offset="1" stopColor="#1c2027" />
|
||||
</>
|
||||
)}
|
||||
</linearGradient>
|
||||
<linearGradient id={gloss} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#ffffff" stopOpacity="0.28" />
|
||||
<stop offset="0.5" stopColor="#ffffff" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* tile */}
|
||||
<rect x="4" y="4" width="92" height="92" rx="26" fill={`url(#${bg})`} />
|
||||
<rect x="4" y="4" width="92" height="92" rx="26" fill={`url(#${gloss})`} />
|
||||
<rect
|
||||
x="4.75"
|
||||
y="4.75"
|
||||
width="90.5"
|
||||
height="90.5"
|
||||
rx="25.25"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
strokeOpacity="0.14"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
|
||||
{/* symbol */}
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{symbol === "home" && (
|
||||
<>
|
||||
<path d="M29 49 L50 30 L71 49" />
|
||||
<path d="M34 46 V70 H66 V46" />
|
||||
</>
|
||||
)}
|
||||
{symbol === "about" && (
|
||||
<>
|
||||
<circle cx="50" cy="41" r="11" />
|
||||
<path d="M30 72 C30 57, 70 57, 70 72" />
|
||||
</>
|
||||
)}
|
||||
{symbol === "contact" && (
|
||||
<>
|
||||
<rect x="27" y="35" width="46" height="30" rx="6" />
|
||||
<path d="M30 40 L50 54 L70 40" />
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
|
||||
{/* filled symbols */}
|
||||
{symbol === "portfolio" && (
|
||||
<g fill="#ffffff">
|
||||
<rect x="32" y="32" width="15" height="15" rx="4" />
|
||||
<rect x="53" y="32" width="15" height="15" rx="4" />
|
||||
<rect x="32" y="53" width="15" height="15" rx="4" />
|
||||
<rect x="53" y="53" width="15" height="15" rx="4" />
|
||||
</g>
|
||||
)}
|
||||
{symbol === "products" && (
|
||||
<g
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M50 28 L71 40 L71 60 L50 72 L29 60 L29 40 Z" />
|
||||
<path d="M29 40 L50 52 L71 40" />
|
||||
<path d="M50 52 L50 72" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { ShieldCheck, SlidersHorizontal, X } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { LocaleToggle } from "@/components/layout/locale-toggle";
|
||||
import { SoundToggle } from "@/components/sound-toggle";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { buildAdminUrl } from "@/lib/admin-routing";
|
||||
import type { AppLocale } from "@/lib/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FloatingControlsProps = {
|
||||
locale: string;
|
||||
defaultLocale: AppLocale;
|
||||
isSuperAdmin?: boolean;
|
||||
};
|
||||
|
||||
const pill =
|
||||
"flex h-11 w-11 items-center justify-center rounded-full border border-border/60 bg-background/80 text-foreground/80 shadow-panel backdrop-blur-chrome transition-colors hover:bg-accent hover:text-foreground";
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 12, scale: 0.8 },
|
||||
show: { opacity: 1, y: 0, scale: 1 },
|
||||
};
|
||||
|
||||
export function FloatingControls({
|
||||
locale,
|
||||
defaultLocale,
|
||||
isSuperAdmin = false,
|
||||
}: FloatingControlsProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const t = useTranslations("navigation");
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
const controls: ReactNode[] = [
|
||||
<SoundToggle
|
||||
key="sound"
|
||||
ariaLabel={t("soundMute")}
|
||||
mutedAriaLabel={t("soundUnmute")}
|
||||
variant="ghost"
|
||||
className={cn(pill, "border-transparent bg-transparent")}
|
||||
/>,
|
||||
<ThemeToggle
|
||||
key="theme"
|
||||
ariaLabel={t("themeToggle")}
|
||||
variant="ghost"
|
||||
className={cn(pill, "border-transparent bg-transparent")}
|
||||
/>,
|
||||
<LocaleToggle
|
||||
key="locale"
|
||||
locale={locale}
|
||||
defaultLocale={defaultLocale}
|
||||
className={cn(pill, "border-transparent bg-transparent p-0")}
|
||||
/>,
|
||||
];
|
||||
|
||||
if (isSuperAdmin) {
|
||||
controls.push(
|
||||
<a key="admin" href={buildAdminUrl("/")} aria-label="Open admin dashboard" className={pill}>
|
||||
<ShieldCheck className="h-[1.125rem] w-[1.125rem]" />
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-0 end-0 z-50 flex flex-col items-center gap-2 p-4"
|
||||
style={{ paddingBottom: "calc(1rem + env(safe-area-inset-bottom, 0px))" }}>
|
||||
<AnimatePresence>
|
||||
{open ? (
|
||||
<motion.div
|
||||
key="tray"
|
||||
className="pointer-events-auto flex flex-col items-center gap-2 rounded-full border border-border/50 bg-background/60 p-1.5 shadow-panel backdrop-blur-chrome"
|
||||
initial={reducedMotion ? { opacity: 0 } : "hidden"}
|
||||
animate={reducedMotion ? { opacity: 1 } : "show"}
|
||||
exit={reducedMotion ? { opacity: 0 } : "hidden"}
|
||||
variants={{
|
||||
hidden: { transition: { staggerChildren: 0.04, staggerDirection: -1 } },
|
||||
show: { transition: { staggerChildren: 0.05, delayChildren: 0.02 } },
|
||||
}}
|
||||
>
|
||||
{controls.map((control, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
variants={reducedMotion ? undefined : itemVariants}
|
||||
transition={{ type: "spring", stiffness: 460, damping: 28 }}
|
||||
>
|
||||
{control}
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
aria-label={open ? t("closeMenu") : t("openMenu")}
|
||||
className={cn(pill, "pointer-events-auto")}
|
||||
>
|
||||
<motion.span
|
||||
animate={{ rotate: open ? 90 : 0 }}
|
||||
transition={reducedMotion ? { duration: 0 } : { type: "spring", stiffness: 400, damping: 24 }}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
{open ? <X className="h-[1.125rem] w-[1.125rem]" /> : <SlidersHorizontal className="h-[1.125rem] w-[1.125rem]" />}
|
||||
</motion.span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+27
-142
@@ -1,25 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { DockAppIcon, type DockSymbol } from "@/components/layout/dock-app-icon";
|
||||
import { LocaleToggle } from "@/components/layout/locale-toggle";
|
||||
import { SoundToggle } from "@/components/sound-toggle";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { FloatingControls } from "@/components/layout/floating-controls";
|
||||
import { Dock, DockIcon } from "@/components/ui/dock";
|
||||
import { buildAdminUrl } from "@/lib/admin-routing";
|
||||
import { getLocalizedPath, stripLocalePrefix, type AppLocale } from "@/lib/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SiteDockProps = {
|
||||
lightLogoUrl?: string | null;
|
||||
darkLogoUrl?: string | null;
|
||||
defaultLocale: AppLocale;
|
||||
/**
|
||||
* Server-computed via `isSuperAdmin()` in the parent layout. This client
|
||||
@@ -30,8 +21,10 @@ type SiteDockProps = {
|
||||
isSuperAdmin?: boolean;
|
||||
};
|
||||
|
||||
type NavKey = "home" | "about" | "portfolio" | "products" | "contact";
|
||||
|
||||
type NavItem = {
|
||||
key: DockSymbol;
|
||||
key: NavKey;
|
||||
path: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
@@ -51,134 +44,16 @@ function isNavItemActive(currentPath: string, itemPath: string) {
|
||||
return currentPath === itemPath || currentPath.startsWith(`${itemPath}/`);
|
||||
}
|
||||
|
||||
function MenuClock({ locale }: { locale: string }) {
|
||||
const [time, setTime] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const format = () =>
|
||||
setTime(
|
||||
new Date().toLocaleTimeString(locale === "ar" ? "ar" : locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
);
|
||||
format();
|
||||
const id = setInterval(format, 20_000);
|
||||
return () => clearInterval(id);
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<span
|
||||
suppressHydrationWarning
|
||||
className="hidden select-none px-1 text-xs font-semibold tabular-nums text-foreground/65 sm:inline"
|
||||
>
|
||||
{time || "--:--"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteDock({
|
||||
lightLogoUrl,
|
||||
darkLogoUrl,
|
||||
defaultLocale,
|
||||
isSuperAdmin = false,
|
||||
}: SiteDockProps) {
|
||||
export function SiteDock({ defaultLocale, isSuperAdmin = false }: SiteDockProps) {
|
||||
const locale = useLocale();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations("navigation");
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const isArabic = locale === "ar";
|
||||
const currentPath = stripLocalePrefix(pathname);
|
||||
const homeHref = getLocalizedPath(locale, "/", defaultLocale);
|
||||
const adminHref = buildAdminUrl("/");
|
||||
|
||||
const activeTheme = theme === "system" ? resolvedTheme : theme;
|
||||
const isDark = mounted && activeTheme === "dark";
|
||||
const logoSrc = isDark
|
||||
? darkLogoUrl || lightLogoUrl || "/logos/dark-primary.svg"
|
||||
: lightLogoUrl || darkLogoUrl || "/logos/light-primary.svg";
|
||||
|
||||
const brandSubtitle = isArabic
|
||||
? "مطوّر ويب · فريلانسر"
|
||||
: "Webdesigner Bremen · Freelancer";
|
||||
|
||||
const controlButtonClassName =
|
||||
"h-8 w-8 rounded-full border border-transparent text-foreground/70 hover:bg-accent hover:text-foreground";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ===== Top menu bar (macOS-style utility strip) ===== */}
|
||||
<header
|
||||
className="fixed inset-x-0 top-0 z-40 border-b border-border/60 bg-background/70 backdrop-blur-chrome"
|
||||
style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
<div className="mx-auto flex h-12 max-w-[100rem] items-center gap-3 px-4 sm:px-6">
|
||||
<Link
|
||||
href={homeHref}
|
||||
className="flex items-center gap-2 rounded-full px-1 py-1 font-black tracking-tight"
|
||||
aria-label="mohfarawati"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-primary shadow-[0_0_10px_hsl(var(--primary))]" />
|
||||
<span className="text-sm" dir={isArabic ? "rtl" : undefined}>
|
||||
{isArabic ? (
|
||||
<>
|
||||
<span className="text-[hsl(var(--hero-ink))] dark:text-foreground">محمد</span>{" "}
|
||||
<span className="text-primary">فرواتي</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[hsl(var(--hero-ink))] dark:text-foreground">MOH</span>
|
||||
<span className="text-primary">FARAWATI</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<span
|
||||
className="hidden border-s border-border/60 ps-3 text-xs font-medium text-foreground/60 md:inline"
|
||||
dir={isArabic ? "rtl" : undefined}
|
||||
>
|
||||
{brandSubtitle}
|
||||
</span>
|
||||
|
||||
<div className="ms-auto flex items-center gap-1">
|
||||
<MenuClock locale={locale} />
|
||||
<LocaleToggle
|
||||
locale={locale}
|
||||
defaultLocale={defaultLocale}
|
||||
className={controlButtonClassName}
|
||||
/>
|
||||
<SoundToggle
|
||||
ariaLabel={t("soundMute")}
|
||||
mutedAriaLabel={t("soundUnmute")}
|
||||
variant="ghost"
|
||||
className={controlButtonClassName}
|
||||
/>
|
||||
<ThemeToggle
|
||||
ariaLabel={t("themeToggle")}
|
||||
variant="ghost"
|
||||
className={controlButtonClassName}
|
||||
/>
|
||||
{isSuperAdmin ? (
|
||||
<a
|
||||
href={adminHref}
|
||||
aria-label="Open admin dashboard"
|
||||
className="ms-1 inline-flex h-8 items-center gap-1.5 rounded-full border border-border/60 bg-background/60 px-3 text-xs font-semibold text-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">Admin</span>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ===== Bottom dock (Magic UI) — primary navigation ===== */}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex justify-center px-4"
|
||||
@@ -186,12 +61,12 @@ export function SiteDock({
|
||||
>
|
||||
<Dock
|
||||
direction="bottom"
|
||||
iconSize={50}
|
||||
iconMagnification={86}
|
||||
iconSize={52}
|
||||
iconMagnification={90}
|
||||
iconDistance={160}
|
||||
className="pointer-events-auto h-[68px] gap-2.5 border-border/60 bg-background/70 shadow-panel"
|
||||
>
|
||||
{/* Logo — first icon in the dock */}
|
||||
{/* Logo — first icon in the dock (fixed src, no theme swap → no flash) */}
|
||||
<DockIcon className="group relative overflow-visible rounded-[26%] bg-white shadow-[0_6px_14px_-6px_rgba(0,0,0,0.5)] ring-1 ring-black/10">
|
||||
<Link
|
||||
href={homeHref}
|
||||
@@ -199,16 +74,13 @@ export function SiteDock({
|
||||
className="flex h-full w-full items-center justify-center rounded-[inherit]"
|
||||
>
|
||||
<Image
|
||||
src={logoSrc}
|
||||
src="/logos/light-primary.svg"
|
||||
alt="mohfarawati"
|
||||
width={96}
|
||||
height={96}
|
||||
sizes="96px"
|
||||
width={104}
|
||||
height={104}
|
||||
sizes="104px"
|
||||
priority
|
||||
className={cn(
|
||||
"h-full w-full rounded-[inherit] object-contain transition-opacity duration-300",
|
||||
mounted ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
className="h-full w-full rounded-[inherit] object-contain"
|
||||
/>
|
||||
</Link>
|
||||
<DockTip label="mohfarawati" />
|
||||
@@ -220,8 +92,18 @@ export function SiteDock({
|
||||
const itemPath = path || "/";
|
||||
const isActive = isNavItemActive(currentPath, itemPath);
|
||||
const label = t(key);
|
||||
const src = `/dock/${key}${isActive ? "-active" : ""}.svg`;
|
||||
|
||||
const icon = <DockAppIcon symbol={key} active={isActive} disabled={disabled} />;
|
||||
const icon = (
|
||||
<Image
|
||||
src={src}
|
||||
alt={label}
|
||||
width={104}
|
||||
height={104}
|
||||
sizes="104px"
|
||||
className={cn("h-full w-full rounded-[inherit] object-contain", disabled && "opacity-55")}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<DockIcon
|
||||
@@ -257,6 +139,9 @@ export function SiteDock({
|
||||
})}
|
||||
</Dock>
|
||||
</div>
|
||||
|
||||
{/* ===== Corner controls — theme / language / sound / admin ===== */}
|
||||
<FloatingControls locale={locale} defaultLocale={defaultLocale} isSuperAdmin={isSuperAdmin} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ const DockIcon = ({
|
||||
...props
|
||||
}: DockIconProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const padding = Math.max(2, size * 0.05);
|
||||
const padding = 0;
|
||||
const defaultMouseX = useMotionValue(Infinity);
|
||||
|
||||
const distanceCalc = useTransform(mouseX ?? defaultMouseX, (val: number) => {
|
||||
|
||||
Reference in New Issue
Block a user