finished navbar and footer in frontend

This commit is contained in:
2025-08-19 00:34:15 +02:00
parent 48b3c8170b
commit 6ec87aa20d
15 changed files with 463 additions and 95 deletions

View File

@@ -1,11 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { Settings } from '$lib/sanity.types'; import type { Settings } from '$lib/sanity.types';
import Logo from './logo.svelte';
let { settings }: { settings: Settings } = $props(); let { settings }: { settings: Settings } = $props();
</script> </script>
<div class="flex flex-col gap-4"> <footer class="bg-white border-t">
<p class="text-sm pb-4 px-8"> <div class="container mx-auto px-4 py-6">
{settings?.footer?.replace('{YEAR}', new Date().getFullYear().toString())} <div class="flex flex-col md:flex-row justify-between items-center gap-4">
<Logo {settings} height={24} />
<div class="text-center md:text-right">
<p class="text-sm text-muted-foreground">
{settings.footer?.replace('{YEAR}', new Date().getFullYear().toString()) || `© ${new Date().getFullYear()} All rights reserved.`}
</p> </p>
</div> </div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import type { Settings } from '$lib/sanity.types';
import { generateImageUrl } from '$lib/helper/image-url';
interface Props {
settings: Settings;
height?: number;
width?: number;
class?: string;
}
let {
settings,
height = 32,
width = 120,
class: className = ''
}: Props = $props();
const logoUrl = generateImageUrl(settings.logo, width, height);
</script>
<div class="flex items-center gap-2 {className}">
{#if logoUrl}
<img src={logoUrl} alt="Logo" class="h-8 w-auto" />
{/if}
{#if settings.title}
<span class="font-semibold text-lg">{settings.title}</span>
{/if}
</div>

View File

@@ -1,4 +1,4 @@
<script context="module" lang="ts"> <script module lang="ts">
export interface NavigationSubItem { export interface NavigationSubItem {
name: string; name: string;
url: string; url: string;
@@ -20,33 +20,56 @@ import {
NavigationMenuRoot, NavigationMenuRoot,
NavigationMenuTrigger NavigationMenuTrigger
} from '$lib/components/ui/navigation-menu'; } from '$lib/components/ui/navigation-menu';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger
} from '$lib/components/ui/sheet';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import { navigationMenuTriggerStyle } from './ui/navigation-menu/navigation-menu-trigger.svelte'; import { navigationMenuTriggerStyle } from './ui/navigation-menu/navigation-menu-trigger.svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { tweened } from 'svelte/motion'; import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import type { Settings } from '$lib/sanity.types';
import Logo from './logo.svelte';
import { MoreHorizontal, ChevronDown, ChevronRight } from '@lucide/svelte';
import { slide } from 'svelte/transition';
import { quintOut } from 'svelte/easing';
export let items: NavigationItem[] = [ interface Props {
settings?: Settings;
items?: NavigationItem[];
}
let {
settings,
items = [
{ name: 'Home', url: '/' }, { name: 'Home', url: '/' },
{ name: 'Test', url: '/test' }, { name: 'Test', url: '/test' },
{ {
name: 'Test Subitems', name: 'Test Subitems',
subitems: [ subitems: [{ name: 'Test Page', url: '#' }]
{ name: 'Test Page', url: '#' }
]
} }
]; ]
}: Props = $props();
let activeIndex = 0; let activeIndex = $state(0);
let navigationList: HTMLElement; let navigationList: HTMLElement;
let mobileMenuOpen = $state(false);
let expandedSubmenu = $state<string | null>(null);
const indicatorPosition = tweened({ left: 0, width: 0 }, { const indicatorPosition = tweened(
{ left: 0, width: 0 },
{
duration: 300, duration: 300,
easing: cubicOut easing: cubicOut
}); }
);
$: { $effect(() => {
const currentPath = $page.url.pathname; const currentPath = $page.url.pathname;
let newActiveIndex = -1; let newActiveIndex = -1;
@@ -54,8 +77,8 @@ import {
if (item.url === currentPath) { if (item.url === currentPath) {
newActiveIndex = index; newActiveIndex = index;
} else if (item.subitems) { } else if (item.subitems) {
const hasActiveSubitem = item.subitems.some(subitem => const hasActiveSubitem = item.subitems.some(
currentPath === subitem.url || currentPath.startsWith(subitem.url + '/') (subitem) => currentPath === subitem.url || currentPath.startsWith(subitem.url + '/')
); );
if (hasActiveSubitem) { if (hasActiveSubitem) {
newActiveIndex = index; newActiveIndex = index;
@@ -67,7 +90,7 @@ import {
activeIndex = newActiveIndex; activeIndex = newActiveIndex;
tick().then(() => updateIndicator()); tick().then(() => updateIndicator());
} }
} });
function updateIndicator() { function updateIndicator() {
if (!navigationList || activeIndex === -1) return; if (!navigationList || activeIndex === -1) return;
@@ -92,31 +115,59 @@ import {
onMount(() => { onMount(() => {
setTimeout(updateIndicator, 200); setTimeout(updateIndicator, 200);
}); });
function closeMobileMenu() {
mobileMenuOpen = false;
expandedSubmenu = null;
}
function toggleSubmenu(itemName: string) {
expandedSubmenu = expandedSubmenu === itemName ? null : itemName;
}
</script> </script>
<NavigationMenuRoot class="px-2 py-1 min-w-full bg-primary" viewport={false}> <nav class="bg-primary py-3">
<div class="container md:mx-auto px-4 md:px-0">
<div class="relative flex items-center">
<div class="flex items-center">
{#if settings}
<Logo {settings} class="text-white" />
{:else}
<div class="text-lg font-semibold text-white">Logo</div>
{/if}
</div>
<div class="absolute left-1/2 hidden -translate-x-1/2 transform md:flex">
<NavigationMenuRoot class="" viewport={false}>
<div bind:this={navigationList}> <div bind:this={navigationList}>
<NavigationMenuList class="relative"> <NavigationMenuList class="relative">
<div <div
class="absolute top-0 h-full bg-white/10 rounded-md pointer-events-none z-10" class="pointer-events-none absolute top-0 z-10 h-full rounded-md bg-white/10"
style="left: {$indicatorPosition.left}px; width: {$indicatorPosition.width}px;" style="left: {$indicatorPosition.left}px; width: {$indicatorPosition.width}px;"
></div> ></div>
{#each items as item} {#each items as item}
<NavigationMenuItem data-navigation-menu-item> <NavigationMenuItem data-navigation-menu-item>
{#if item.url && !item.subitems} {#if item.url && !item.subitems}
<NavigationMenuLink <NavigationMenuLink
class={cn(navigationMenuTriggerStyle(), "relative z-20")} class={cn(
'relative z-20 text-white hover:text-white/80 transition-colors'
)}
href={item.url} href={item.url}
> >
{item.name} {item.name}
</NavigationMenuLink> </NavigationMenuLink>
{:else if item.subitems} {:else if item.subitems}
<NavigationMenuTrigger class="relative z-20"> <NavigationMenuTrigger class="relative z-20 text-white hover:text-white/80 transition-colors">
{item.name} {item.name}
</NavigationMenuTrigger> </NavigationMenuTrigger>
<NavigationMenuContent class="absolute left-1/2 -translate-x-1/2 mt-2 z-50 min-w-full border border-white/20 bg-primary shadow-lg rounded-md"> <NavigationMenuContent
class="bg-primary absolute left-1/2 z-50 mt-2 min-w-full -translate-x-1/2 rounded-md border border-white/20 shadow-lg"
>
{#each item.subitems as subitem} {#each item.subitems as subitem}
<NavigationMenuLink href={subitem.url}> <NavigationMenuLink
href={subitem.url}
class="block rounded-md px-4 py-2 text-white hover:text-white/80 transition-colors"
>
{subitem.name} {subitem.name}
</NavigationMenuLink> </NavigationMenuLink>
{/each} {/each}
@@ -127,3 +178,78 @@ import {
</NavigationMenuList> </NavigationMenuList>
</div> </div>
</NavigationMenuRoot> </NavigationMenuRoot>
</div>
<div class="ml-auto flex items-center">
<Sheet bind:open={mobileMenuOpen}>
<SheetTrigger
class="rounded-md p-2 text-white transition-colors hover:bg-white/10 md:hidden"
aria-label="Toggle mobile menu"
>
<MoreHorizontal size={24} />
</SheetTrigger>
<SheetContent side="top" class="h-full w-full bg-primary border-primary">
<SheetHeader class="border-b border-white/20 pb-4">
<SheetTitle class="flex items-center justify-start text-white">
{#if settings}
<Logo {settings} class="text-white" />
{:else}
<div class="text-lg font-semibold text-white">Navigation</div>
{/if}
</SheetTitle>
</SheetHeader>
<div class="flex flex-col space-y-2 pt-6 relative">
{#each items as item, index}
{#if item.url && !item.subitems}
<div class="relative">
{#if index === activeIndex}
<div class="absolute left-0 top-0 bottom-0 w-1 bg-white/20 rounded-r-md"></div>
{/if}
<a
href={item.url}
class="block rounded-md px-4 py-2 text-sm transition-colors text-white hover:text-white/80 {index === activeIndex ? 'text-white' : ''}"
onclick={closeMobileMenu}
>
{item.name}
</a>
</div>
{:else if item.subitems}
<div class="space-y-1 relative">
{#if index === activeIndex}
<div class="absolute left-0 top-0 bottom-0 w-1 bg-white/20 rounded-r-md"></div>
{/if}
<button
class="w-full rounded-md px-4 py-2 text-left text-sm font-medium transition-all duration-200 flex items-center justify-between text-white hover:text-white/80"
onclick={() => toggleSubmenu(item.name)}
>
<span>{item.name}</span>
<div class="transition-transform duration-300 ease-out {expandedSubmenu === item.name ? 'rotate-90' : 'rotate-0'}">
<ChevronRight size={16} />
</div>
</button>
{#if expandedSubmenu === item.name}
<div
class="ml-4 space-y-1"
transition:slide={{ duration: 300, easing: quintOut }}
>
{#each item.subitems as subitem}
<a
href={subitem.url}
class="block rounded-md px-6 py-2 text-sm transition-colors text-white/80 hover:text-white"
onclick={closeMobileMenu}
>
{subitem.name}
</a>
{/each}
</div>
{/if}
</div>
{/if}
{/each}
</div>
</SheetContent>
</Sheet>
</div>
</div>
</div>
</nav>

View File

@@ -37,7 +37,7 @@
<section <section
class={cn( class={cn(
'min-h-screen flex flex-col md:flex-row w-full overflow-hidden relative', 'flex flex-col md:flex-row w-full',
textColor textColor
)} )}
style:background-image={background?.url ? `url(${background.url})` : undefined} style:background-image={background?.url ? `url(${background.url})` : undefined}
@@ -46,10 +46,10 @@
style:background-repeat="no-repeat" style:background-repeat="no-repeat"
aria-label={background?.alt} aria-label={background?.alt}
> >
<div class={cn('absolute inset-0 z-0', backgroundColor)}></div> <div class={cn('absolute inset-0 z-[-1]', backgroundColor)}></div>
<div <div
class="z-10 flex flex-col items-center md:items-start justify-center md:justify-start mt-20 px-6 md:px-20 md:py-32 md:flex-1 space-y-12 min-h-screen md:min-h-0" class="z-10 flex flex-col items-center md:items-start justify-center md:justify-start mt-20 px-6 md:px-20 md:py-32 md:flex-1 space-y-12"
> >
<div> <div>
{#if sectionTitle} {#if sectionTitle}

View File

@@ -13,7 +13,7 @@
bind:ref bind:ref
data-slot="navigation-menu-link" data-slot="navigation-menu-link"
class={cn( class={cn(
"data-[active=true]:focus:bg-white/5 cursor-pointer data-[active=true]:hover:bg-white/5 data-[active=true]:bg-primary data-[active=true]:text-white hover:bg-white/5 hover:text-white focus:bg-white/5 focus:text-white active:bg-white/8 focus-visible:ring-white/20 [&_svg:not([class*='text-'])]:text-white flex flex-col gap-1 rounded-sm p-2 text-sm text-white outline-none transition-all focus-visible:outline-1 focus-visible:ring-[3px] [&_svg:not([class*='size-'])]:size-4", "data-[active=true]:focus:bg-white/5 cursor-pointer data-[active=true]:text-white hover:text-white/80 focus:text-white focus-visible:ring-white/20 [&_svg:not([class*='text-'])]:text-white flex flex-col gap-1 rounded-sm p-2 text-sm text-white outline-none transition-all focus-visible:outline-1 focus-visible:ring-[3px] [&_svg:not([class*='size-'])]:size-4",
className className
)} )}
{...restProps} {...restProps}

View File

@@ -0,0 +1,36 @@
import { Dialog as SheetPrimitive } from "bits-ui";
import Trigger from "./sheet-trigger.svelte";
import Close from "./sheet-close.svelte";
import Overlay from "./sheet-overlay.svelte";
import Content from "./sheet-content.svelte";
import Header from "./sheet-header.svelte";
import Footer from "./sheet-footer.svelte";
import Title from "./sheet-title.svelte";
import Description from "./sheet-description.svelte";
const Root = SheetPrimitive.Root;
const Portal = SheetPrimitive.Portal;
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
</script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />

View File

@@ -0,0 +1,58 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sheetVariants = tv({
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
variants: {
side: {
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
left: "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
right: "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
});
export type Side = VariantProps<typeof sheetVariants>["side"];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
side = "right",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: SheetPrimitive.PortalProps;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPrimitive.Portal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
class={cn(sheetVariants({ side }), className)}
{...restProps}
>
{@render children?.()}
<SheetPrimitive.Close
class="ring-offset-background focus-visible:ring-ring rounded-xs focus-visible:outline-hidden absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
data-slot="sheet-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-footer"
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-header"
class={cn("flex flex-col gap-1.5 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
data-slot="sheet-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
data-slot="sheet-title"
class={cn("text-foreground font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
</script>
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />

View File

@@ -24,8 +24,10 @@
</svelte:head> </svelte:head>
<div class="scroll-smooth font-sans antialiased"> <div class="scroll-smooth font-sans antialiased">
<Navbar /> <Navbar settings={data.settings} />
<main class="min-h-[calc(100vh-11.3rem)] md:min-h-[calc(100vh-8.55rem)]">
{@render children()} {@render children()}
</main>
{#if data.settings} {#if data.settings}
<Footer settings={data.settings} /> <Footer settings={data.settings} />
{/if} {/if}