finished navbar and blog. todo: finishing touches

This commit is contained in:
2025-08-21 02:03:21 +02:00
parent 519eb1ff2a
commit da67406b5f
49 changed files with 3260 additions and 626 deletions

View File

@@ -1,131 +1,142 @@
@import "tailwindcss";
@import 'tailwindcss';
@import "tw-animate-css";
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@layer base {
:root {
--font-sans: 'Geist', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
body {
font-family: var(--font-sans);
}
:root {
--font-sans:
'Geist', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}
body {
font-family: var(--font-sans);
}
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@layer utilities {
.line-clamp-3 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
}

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<body data-sveltekit-preload-data="false">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
let { children } = $props();
</script>
<blockquote
class="my-6 rounded-r-lg border-l-4 border-primary bg-muted/30 py-4 pl-6 text-lg text-muted-foreground italic"
>
{@render children()}
</blockquote>

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import { cn } from '$lib/utils';
let { portableText, children } = $props();
const { value } = portableText;
const getHeadingClass = (style: string) => {
const baseClasses = 'font-bold tracking-tight';
switch (style) {
case 'h1':
return cn(baseClasses, 'text-4xl md:text-5xl lg:text-6xl mb-6 mt-8');
case 'h2':
return cn(baseClasses, 'text-3xl md:text-4xl mb-4 mt-8');
case 'h3':
return cn(baseClasses, 'text-2xl md:text-3xl mb-3 mt-6');
case 'h4':
return cn(baseClasses, 'text-xl md:text-2xl mb-3 mt-6');
case 'h5':
return cn(baseClasses, 'text-lg md:text-xl mb-2 mt-4');
case 'h6':
return cn(baseClasses, 'text-base md:text-lg mb-2 mt-4');
default:
return cn(baseClasses, 'text-xl mb-3 mt-6');
}
};
</script>
{#if value.style === 'h1'}
<h1 class={getHeadingClass(value.style)}>
{@render children()}
</h1>
{:else if value.style === 'h2'}
<h2 class={getHeadingClass(value.style)}>
{@render children()}
</h2>
{:else if value.style === 'h3'}
<h3 class={getHeadingClass(value.style)}>
{@render children()}
</h3>
{:else if value.style === 'h4'}
<h4 class={getHeadingClass(value.style)}>
{@render children()}
</h4>
{:else if value.style === 'h5'}
<h5 class={getHeadingClass(value.style)}>
{@render children()}
</h5>
{:else if value.style === 'h6'}
<h6 class={getHeadingClass(value.style)}>
{@render children()}
</h6>
{/if}

View File

@@ -0,0 +1,7 @@
<script lang="ts">
let { children } = $props();
</script>
<p class="mb-4 leading-relaxed text-foreground">
{@render children()}
</p>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { getImageDimensions } from '@sanity/asset-utils';
import { client } from '$lib/sanity';
import imageUrlBuilder from '@sanity/image-url';
let { image, alt = '', width = 1200, height = 600, class: className = '' } = $props();
const { projectId, dataset } = client.config();
const builder = imageUrlBuilder({ projectId: projectId ?? '', dataset: dataset ?? '' });
function generateCoverImageUrl(
imageAsset: any,
targetWidth: number,
targetHeight: number
): string {
if (!imageAsset || !projectId || !dataset) {
return '';
}
const imageRef = imageAsset.asset?._ref || imageAsset.asset?._id || imageAsset.asset;
if (!imageRef) {
return '';
}
try {
const imageBuilder = builder
.image(imageRef)
.fit('crop')
.crop('center')
.width(targetWidth)
.height(targetHeight)
.format('webp')
.auto('format');
return imageBuilder.url();
} catch (error) {
console.error('Error generating cover image URL:', error);
return '';
}
}
const imageUrl = $derived(generateCoverImageUrl(image, width, height));
const dimensions = $derived(
image?.asset ? getImageDimensions(image) : { width: width, height: height }
);
</script>
{#if imageUrl}
<img
src={imageUrl}
{alt}
{width}
{height}
loading="lazy"
class="h-full w-full object-cover object-center {className}"
/>
{/if}

View File

@@ -45,17 +45,7 @@
items?: NavigationItem[];
}
let {
settings,
items = [
{ name: 'Home', url: '/' },
{ name: 'Test', url: '/test' },
{
name: 'Test Subitems',
subitems: [{ name: 'Test Page', url: '#' }]
}
]
}: Props = $props();
let { settings, items = [] }: Props = $props();
let activeIndex = $state(0);
let navigationList: HTMLElement;
@@ -74,7 +64,7 @@
const currentPath = $page.url.pathname;
let newActiveIndex = -1;
items.forEach((item, index) => {
items?.forEach((item, index) => {
if (item.url === currentPath) {
newActiveIndex = index;
} else if (item.subitems) {
@@ -125,15 +115,17 @@
function toggleSubmenu(itemName: string) {
expandedSubmenu = expandedSubmenu === itemName ? null : itemName;
}
const shouldReload = $derived($page.url.pathname.startsWith('/blog/'));
</script>
<nav class="bg-primary py-3">
<nav class="relative z-[50] bg-primary py-3">
<div class="container px-4 md:mx-auto md:px-0">
<div class="relative flex items-center">
<div class="flex items-center">
{#if settings}
<a href="/">
<Button variant="ghost" class="px-1">
<Button variant="ghost">
<Logo {settings} class="text-white" />
</Button>
</a>
@@ -143,35 +135,56 @@
</div>
<div class="absolute left-1/2 hidden -translate-x-1/2 transform md:flex">
<NavigationMenuRoot class="" viewport={false}>
<NavigationMenuRoot class="relative z-[50]" viewport={false}>
<div bind:this={navigationList}>
<NavigationMenuList class="relative">
<div
class="pointer-events-none absolute top-0 z-10 h-full rounded-md bg-white/10"
class="pointer-events-none absolute top-1/2 z-10 h-9 -translate-y-1/2 rounded-md bg-white/10"
style="left: {$indicatorPosition.left}px; width: {$indicatorPosition.width}px;"
></div>
{#each items as item}
{#each items || [] as item}
<NavigationMenuItem data-navigation-menu-item>
{#if item.url && !item.subitems}
<NavigationMenuLink
class={cn('relative z-20 text-white transition-colors hover:text-white/80')}
href={item.url}
data-sveltekit-reload={item.url?.startsWith('/blog/') && shouldReload
? 'true'
: undefined}
>
{item.name}
</NavigationMenuLink>
{:else if item.subitems}
<NavigationMenuTrigger
class="relative z-20 text-white transition-colors hover:text-white/80"
>
{item.name}
</NavigationMenuTrigger>
{#if item.url}
<NavigationMenuTrigger
class="relative z-20 cursor-pointer px-2 text-white transition-colors hover:text-white/80"
>
<NavigationMenuLink
href={item.url}
data-sveltekit-reload={item.url?.startsWith('/blog/') && shouldReload
? 'true'
: undefined}
>
{item.name}
</NavigationMenuLink>
</NavigationMenuTrigger>
{:else}
<NavigationMenuTrigger
class="relative z-20 cursor-pointer text-white transition-colors hover:text-white/80"
>
{item.name}
</NavigationMenuTrigger>
{/if}
<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"
class="absolute left-1/2 z-[50] mt-2 min-w-max -translate-x-1/2 rounded-md border border-white/20 bg-primary shadow-lg"
>
{#each item.subitems as subitem}
<NavigationMenuLink
href={subitem.url}
class="block rounded-md px-4 py-2 text-white transition-colors hover:text-white/80"
data-sveltekit-reload={subitem.url?.startsWith('/blog/') && shouldReload
? 'true'
: undefined}
>
{subitem.name}
</NavigationMenuLink>
@@ -191,7 +204,7 @@
<MoreHorizontal size={24} />
</Button>
</SheetTrigger>
<SheetContent side="top" class="bg-primary border-primary h-full w-full text-white">
<SheetContent side="top" class="h-full w-full border-primary bg-primary text-white">
<SheetHeader class="border-b border-white/20 pb-4">
<SheetTitle class="flex items-center justify-start">
{#if settings}
@@ -206,7 +219,7 @@
</SheetTitle>
</SheetHeader>
<div class="relative flex flex-col space-y-2 pt-6">
{#each items as item, index}
{#each items || [] as item, index}
{#if item.url && !item.subitems}
<div class="relative">
<a
@@ -216,6 +229,9 @@
index === activeIndex && 'bg-white/20'
)}
onclick={closeMobileMenu}
data-sveltekit-reload={item.url?.startsWith('/blog/') && shouldReload
? 'true'
: undefined}
>
{item.name}
</a>
@@ -224,7 +240,7 @@
<div class="relative space-y-1">
{#if index === activeIndex}
<div
class="absolute bottom-0 left-0 top-0 w-1 rounded-r-md bg-white/20"
class="absolute top-0 bottom-0 left-0 w-1 rounded-r-md bg-white/20"
></div>
{/if}
<Button
@@ -246,11 +262,26 @@
class="ml-4 space-y-1"
transition:slide={{ duration: 300, easing: quintOut }}
>
{#if item.url}
<a
href={item.url}
class="block rounded-md px-6 py-2 text-sm text-white/80 transition-colors hover:text-white"
onclick={closeMobileMenu}
data-sveltekit-reload={item.url?.startsWith('/blog/') && shouldReload
? 'true'
: undefined}
>
{item.name}
</a>
{/if}
{#each item.subitems as subitem}
<a
href={subitem.url}
class="block rounded-md px-6 py-2 text-sm text-white/80 transition-colors hover:text-white"
onclick={closeMobileMenu}
data-sveltekit-reload={subitem.url?.startsWith('/blog/') && shouldReload
? 'true'
: undefined}
>
{subitem.name}
</a>

View File

@@ -6,13 +6,24 @@
import SanityButton from './blocks/sanity-button.svelte';
import Callout from './blocks/callout.svelte';
import LinkMark from './blocks/link-mark.svelte';
import Heading from './blocks/heading.svelte';
import Blockquote from './blocks/blockquote.svelte';
import Paragraph from './blocks/paragraph.svelte';
let { body }: { body: BlockContent } = $props();
</script>
<div class="prose prose-lg max-w-none">
<PortableText
value={body}
<div
class="prose prose-lg prose-strong:font-bold
prose-em:italic prose-code:bg-muted
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm prose-code:font-mono prose-a:text-primary
prose-a:hover:underline prose-ul:list-disc
prose-ul:ml-6 prose-ul:space-y-2 prose-ol:list-decimal
prose-ol:ml-6 prose-ol:space-y-2 prose-li:leading-relaxed
max-w-none"
>
<PortableText
value={body}
components={{
types: {
callout: Callout,
@@ -21,9 +32,19 @@
file: SanityFile,
button: SanityButton
},
block: {
h1: Heading,
h2: Heading,
h3: Heading,
h4: Heading,
h5: Heading,
h6: Heading,
blockquote: Blockquote,
normal: Paragraph
},
marks: {
link: LinkMark
}
}}
}}
/>
</div>

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="card-action"
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<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="card-content" class={cn("px-6", 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<HTMLParagraphElement>> = $props();
</script>
<p
bind:this={ref}
data-slot="card-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
>
{@render children?.()}
</p>

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="card-footer"
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<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="card-header"
class={cn(
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
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="card-title"
class={cn("font-semibold leading-none", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<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="card"
class={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,25 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Action from "./card-action.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
Action as CardAction,
};

View File

@@ -0,0 +1,25 @@
import Root from "./pagination.svelte";
import Content from "./pagination-content.svelte";
import Item from "./pagination-item.svelte";
import Link from "./pagination-link.svelte";
import PrevButton from "./pagination-prev-button.svelte";
import NextButton from "./pagination-next-button.svelte";
import Ellipsis from "./pagination-ellipsis.svelte";
export {
Root,
Content,
Item,
Link,
PrevButton,
NextButton,
Ellipsis,
//
Root as Pagination,
Content as PaginationContent,
Item as PaginationItem,
Link as PaginationLink,
PrevButton as PaginationPrevButton,
NextButton as PaginationNextButton,
Ellipsis as PaginationEllipsis,
};

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<HTMLUListElement>> = $props();
</script>
<ul
bind:this={ref}
data-slot="pagination-content"
class={cn("flex flex-row items-center gap-1", className)}
{...restProps}
>
{@render children?.()}
</ul>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import EllipsisIcon from "@lucide/svelte/icons/ellipsis";
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
</script>
<span
bind:this={ref}
aria-hidden="true"
data-slot="pagination-ellipsis"
class={cn("flex size-9 items-center justify-center", className)}
{...restProps}
>
<EllipsisIcon class="size-4" />
<span class="sr-only">More pages</span>
</span>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import type { HTMLLiAttributes } from "svelte/elements";
import type { WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
children,
...restProps
}: WithElementRef<HTMLLiAttributes> = $props();
</script>
<li bind:this={ref} data-slot="pagination-item" {...restProps}>
{@render children?.()}
</li>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import { type Props, buttonVariants } from "$lib/components/ui/button/index.js";
let {
ref = $bindable(null),
class: className,
size = "icon",
isActive,
page,
children,
...restProps
}: PaginationPrimitive.PageProps &
Props & {
isActive: boolean;
} = $props();
</script>
{#snippet Fallback()}
{page.value}
{/snippet}
<PaginationPrimitive.Page
bind:ref
{page}
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
class={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from "bits-ui";
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: PaginationPrimitive.NextButtonProps = $props();
</script>
{#snippet Fallback()}
<span>Next</span>
<ChevronRightIcon class="size-4" />
{/snippet}
<PaginationPrimitive.NextButton
bind:ref
aria-label="Go to next page"
class={cn(
buttonVariants({
size: "default",
variant: "ghost",
class: "gap-1 px-2.5 sm:pr-2.5",
}),
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from "bits-ui";
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
import { buttonVariants } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: PaginationPrimitive.PrevButtonProps = $props();
</script>
{#snippet Fallback()}
<ChevronLeftIcon class="size-4" />
<span>Previous</span>
{/snippet}
<PaginationPrimitive.PrevButton
bind:ref
aria-label="Go to previous page"
class={cn(
buttonVariants({
size: "default",
variant: "ghost",
class: "gap-1 px-2.5 sm:pl-2.5",
}),
className
)}
children={children || Fallback}
{...restProps}
/>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { Pagination as PaginationPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
count = 0,
perPage = 10,
page = $bindable(1),
siblingCount = 1,
...restProps
}: PaginationPrimitive.RootProps = $props();
</script>
<PaginationPrimitive.Root
bind:ref
bind:page
role="navigation"
aria-label="pagination"
data-slot="pagination"
class={cn("mx-auto flex w-full justify-center", className)}
{count}
{perPage}
{siblingCount}
{...restProps}
/>

View File

@@ -50,7 +50,9 @@ export async function deconstructLink(
href =
resolved.type === 'custom'
? `/${resolved.slug || ''}`
: `/${resolved.type}/${resolved.slug || ''}`;
: resolved.type === 'blog'
? `/blog/${resolved.slug || ''}`
: `/${resolved.type}/${resolved.slug || ''}`;
}
}
break;

View File

@@ -0,0 +1,182 @@
import type { NavigationItem } from '$lib/components/navbar.svelte';
import type { Navbar } from '$lib/sanity.types';
import { serverClient } from '$lib/server/sanity';
import { deconstructLink } from './link';
interface PageResult {
_id: string;
_type: string;
title: string;
slug: {
current: string;
};
}
export async function fetchNavigation(): Promise<NavigationItem[]> {
try {
const navbar = await serverClient.fetch<Navbar>(`
*[_type == "navbar"][0]{
_id,
_type,
title,
links[]{
_key,
text,
link,
sublinks[]{
_key,
type,
text,
link,
pageType,
tagFilter->{
_id,
title,
slug
},
tagPageType
}
}
}
`);
if (!navbar?.links || navbar.links.length === 0) {
// Return fallback navigation when no navbar document exists
return [
{ name: 'Home', url: '/' },
{ name: 'About', url: '/about' },
{ name: 'Contact', url: '/contact' }
];
}
const navigationItems: NavigationItem[] = [];
for (const link of navbar.links) {
if (!link.text) continue;
const deconstructedLink = await deconstructLink(link.link);
const navigationItem: NavigationItem = {
name: link.text,
url: deconstructedLink?.href || '#'
};
// Process sublinks if they exist
if (link.sublinks && link.sublinks.length > 0) {
const subitems = [];
for (const sublink of link.sublinks) {
switch (sublink.type) {
case 'manual': {
if (sublink.text && sublink.link) {
const deconstructedSublink = await deconstructLink(sublink.link);
if (deconstructedSublink?.href) {
subitems.push({
name: sublink.text,
url: deconstructedSublink.href
});
}
}
break;
}
case 'auto': {
const autoPages = await fetchAutoPages(sublink.pageType || 'custom');
for (const page of autoPages) {
const pageUrl =
page._type === 'custom'
? `/${page.slug.current}`
: `/${page._type}/${page.slug.current}`;
subitems.push({
name: page.title,
url: pageUrl
});
}
break;
}
case 'tag': {
if (sublink.tagFilter?._ref && sublink.tagPageType) {
const tagPages = await fetchPagesByTag(sublink.tagFilter._ref, sublink.tagPageType);
for (const page of tagPages) {
const pageUrl =
page._type === 'custom'
? `/${page.slug.current}`
: `/${page._type}/${page.slug.current}`;
subitems.push({
name: page.title,
url: pageUrl
});
}
}
break;
}
}
}
if (subitems.length > 0) {
navigationItem.subitems = subitems;
}
}
navigationItems.push(navigationItem);
}
return navigationItems;
} catch (error) {
console.error('Failed to fetch navigation:', error);
return [];
}
}
async function fetchAutoPages(pageType: string): Promise<PageResult[]> {
const query = `*[_type == $pageType && defined(slug.current)] | order(_createdAt desc)[0...5]{
_id,
_type,
title,
slug
}`;
try {
return await serverClient.fetch<PageResult[]>(query, { pageType });
} catch (error) {
console.error(`Failed to fetch auto pages for type ${pageType}:`, error);
return [];
}
}
async function fetchPagesByTag(tagId: string, pageType: string): Promise<PageResult[]> {
const query = `*[_type == $pageType && defined(slug.current) && $tagId in tags[]._ref] | order(_createdAt desc)[0...5]{
_id,
_type,
title,
slug
}`;
try {
return await serverClient.fetch<PageResult[]>(query, { tagId, pageType });
} catch (error) {
console.error(`Failed to fetch pages by tag ${tagId} for type ${pageType}:`, error);
return [];
}
}
// Cache the navigation data for better performance
let navigationCache: NavigationItem[] | null = null;
let cacheTimestamp: number = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
export async function getCachedNavigation(): Promise<NavigationItem[]> {
const now = Date.now();
if (navigationCache && now - cacheTimestamp < CACHE_DURATION) {
return navigationCache;
}
navigationCache = await fetchNavigation();
cacheTimestamp = now;
return navigationCache;
}

View File

@@ -20,9 +20,92 @@ export type Home = {
_updatedAt: string;
_rev: string;
title?: string;
publishedAt?: string;
headerSection?: CtaSection;
};
export type Blog = {
_id: string;
_type: 'blog';
_createdAt: string;
_updatedAt: string;
_rev: string;
title?: string;
slug?: Slug;
author?: string;
publishedAt?: string;
tags?: Array<string>;
excerpt?: string;
mainImage?: {
asset?: {
_ref: string;
_type: 'reference';
_weak?: boolean;
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset';
};
media?: unknown;
hotspot?: SanityImageHotspot;
crop?: SanityImageCrop;
alt?: string;
_type: 'imageWithAlt';
};
body?: Array<
| {
children?: Array<{
marks?: Array<string>;
text?: string;
_type: 'span';
_key: string;
}>;
style?: 'normal' | 'h1' | 'h2' | 'h3' | 'h4' | 'blockquote';
listItem?: 'bullet';
markDefs?: Array<
| {
href?: Link;
_type: 'link';
_key: string;
}
| ({
_key: string;
} & TextColor)
| ({
_key: string;
} & HighlightColor)
>;
level?: number;
_type: 'block';
_key: string;
}
| ({
_key: string;
} & Button)
| {
asset?: {
_ref: string;
_type: 'reference';
_weak?: boolean;
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset';
};
media?: unknown;
hotspot?: SanityImageHotspot;
crop?: SanityImageCrop;
_type: 'image';
_key: string;
}
| {
asset?: {
_ref: string;
_type: 'reference';
_weak?: boolean;
[internalGroqTypeReferenceTo]?: 'sanity.fileAsset';
};
media?: unknown;
_type: 'file';
_key: string;
}
>;
};
export type FaqSection = {
_type: 'faqSection';
sectionTitle?: string;
@@ -200,6 +283,45 @@ export type Button = {
link?: Link;
};
export type Navbar = {
_id: string;
_type: 'navbar';
_createdAt: string;
_updatedAt: string;
_rev: string;
title?: string;
links?: Array<{
text?: string;
link?: Link;
sublinks?: Array<{
type?: 'auto' | 'tag' | 'manual';
pageType?: 'custom' | 'blog';
text?: string;
link?: Link;
tagFilter?: {
_ref: string;
_type: 'reference';
_weak?: boolean;
[internalGroqTypeReferenceTo]?: 'tag';
};
tagPageType?: 'custom' | 'blog';
_key: string;
}>;
_key: string;
}>;
};
export type Tag = {
_id: string;
_type: 'tag';
_createdAt: string;
_updatedAt: string;
_rev: string;
title?: string;
slug?: Slug;
description?: string;
};
export type Settings = {
_id: string;
_type: 'settings';
@@ -401,6 +523,8 @@ export type Custom = {
_rev: string;
title?: string;
slug?: Slug;
tags?: Array<string>;
publishedAt?: string;
body?: BlockContent;
};
@@ -543,12 +667,15 @@ export type SanityAssetSourceData = {
export type AllSanitySchemaTypes =
| Home
| Blog
| FaqSection
| CtaSection
| ContactSection
| ImageWithAlt
| Faq
| Button
| Navbar
| Tag
| Settings
| BlockContent
| HighlightColor

View File

@@ -1,13 +1,21 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
export function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}

View File

@@ -0,0 +1,106 @@
import { browser } from '$app/environment';
import { serverClient } from '$lib/server/sanity';
import type { Blog } from '$lib/sanity.types';
const BLOGS_PER_PAGE = 12;
const BLOGS_QUERY = `*[_type == "blog" && defined(slug.current)] | order(publishedAt desc, _createdAt desc)[$start...$end]{
_id,
_type,
_createdAt,
_updatedAt,
title,
slug,
author,
publishedAt,
tags,
excerpt,
mainImage,
body[0...2]
}`;
const TOTAL_BLOGS_QUERY = `count(*[_type == "blog" && defined(slug.current)])`;
export interface BlogPaginationData {
blogs: Blog[];
pagination: {
currentPage: number;
totalPages: number;
totalBlogs: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
export async function fetchBlogsClientSide(page: number = 1): Promise<BlogPaginationData> {
if (!browser) {
throw new Error('This function should only be called on the client side');
}
const start = (page - 1) * BLOGS_PER_PAGE;
const end = start + BLOGS_PER_PAGE;
try {
const [blogs, totalBlogs]: [Blog[], number] = await Promise.all([
serverClient.fetch(BLOGS_QUERY, { start, end }),
serverClient.fetch(TOTAL_BLOGS_QUERY)
]);
const totalPages = Math.ceil(totalBlogs / BLOGS_PER_PAGE);
// Process blogs to extract descriptions if excerpt doesn't exist
const processedBlogs = blogs.map((blog) => {
let description = blog.excerpt;
if (!description && blog.body && Array.isArray(blog.body)) {
const firstBlock = blog.body[0];
if (
firstBlock &&
firstBlock._type === 'block' &&
'children' in firstBlock &&
Array.isArray(firstBlock.children)
) {
description = firstBlock.children
.filter((child: any) => child.text)
.map((child: any) => child.text)
.join(' ')
.slice(0, 160);
}
}
return {
...blog,
description
};
});
return {
blogs: processedBlogs,
pagination: {
currentPage: page,
totalPages,
totalBlogs,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1
}
};
} catch (error) {
console.error('Error fetching blogs client-side:', error);
throw error;
}
}
// Alternative: Create an API endpoint for client-side fetching
export async function fetchBlogsViaAPI(page: number = 1): Promise<BlogPaginationData> {
if (!browser) {
throw new Error('This function should only be called on the client side');
}
const response = await fetch(`/api/blogs?page=${page}`);
if (!response.ok) {
throw new Error('Failed to fetch blogs');
}
return response.json();
}

View File

@@ -1,14 +1,24 @@
import type {LayoutServerLoad} from './$types'
import { fetchSettings } from '$lib/settings'
import { getImage } from '$lib/helper/asset-to-url'
import { fetchSettings } from '$lib/settings';
import { getImage } from '$lib/helper/asset-to-url';
import { getCachedNavigation } from '$lib/helper/navigation';
import type { NavigationItem } from '$lib/components/navbar.svelte';
export const load: LayoutServerLoad = async ({locals: {preview}}) => {
const settings = await fetchSettings()
const logo = settings?.logo?.asset?._ref ? await getImage(settings.logo.asset._ref) : null
return {
preview,
settings,
logo
}
}
export const load = async ({
locals: { preview }
}): Promise<{
preview: boolean;
settings: any;
logo: any;
navigation: NavigationItem[];
}> => {
const settings = await fetchSettings();
const logo = settings?.logo?.asset?._ref ? await getImage(settings.logo.asset._ref) : null;
const navigation = await getCachedNavigation();
return {
preview,
settings,
logo,
navigation
};
};

View File

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

View File

@@ -0,0 +1,66 @@
import type { PageServerLoad } from './$types';
import { serverClient } from '$lib/server/sanity';
import type { Custom } from '$lib/sanity.types';
import { error } from '@sveltejs/kit';
const CUSTOM_QUERY = `*[_type == "custom" && slug.current == $slug][0]{
_id,
_type,
_createdAt,
_updatedAt,
_rev,
title,
slug,
tags,
publishedAt,
body,
}`;
export const load: PageServerLoad = async ({ params }) => {
const { slug } = params;
if (!slug) {
throw error(404, 'Slug not found');
}
try {
const custom: Custom = await serverClient.fetch(CUSTOM_QUERY, { slug });
if (!custom) {
throw error(404, 'Page not found');
}
// Extract description from first block
let description: string | undefined;
if (!description && custom.body && Array.isArray(custom.body)) {
const firstBlock = custom.body[0];
if (
firstBlock &&
firstBlock._type === 'block' &&
'children' in firstBlock &&
Array.isArray(firstBlock.children)
) {
description = firstBlock.children
.filter((child: any) => child.text)
.map((child: any) => child.text)
.join(' ')
.slice(0, 160);
}
}
return {
custom,
meta: {
title: custom.title,
description,
url: `/${custom.slug?.current}`,
publishedAt: custom.publishedAt,
tags: custom.tags
}
};
} catch (err) {
console.error('Error fetching custom page:', err);
throw error(500, 'Failed to load page');
}
};

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import type { PageData } from './$types';
import SanityBlock from '$lib/components/sanity-block.svelte';
let { data }: { data: PageData } = $props();
const { custom, meta } = data;
const firstWord = custom.title ? custom.title.split(' ')[0] : '';
</script>
<svelte:head>
<title>{meta.title || custom.title || 'Page'}</title>
<meta name="description" content={meta.description || ''} />
<meta property="og:title" content={meta.title || custom.title || ''} />
<meta property="og:description" content={meta.description || ''} />
<meta property="og:type" content="article" />
<meta property="og:url" content={meta.url || ''} />
{#if meta.publishedAt}
<meta property="article:published_time" content={meta.publishedAt} />
{/if}
{#if meta.tags}
{#each meta.tags as tag}
<meta property="article:tag" content={tag} />
{/each}
{/if}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={meta.title || custom.title || ''} />
<meta name="twitter:description" content={meta.description || ''} />
</svelte:head>
<main class="container mx-auto min-h-screen max-w-3xl md:max-w-4xl p-8 flex flex-col gap-4">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold mb-4 sm:mb-8 font-serif">
{custom.title}
</h1>
{#if custom.body}
<div class="items-start mt-2 mb-8 text-left" style="max-width: 100%;">
<SanityBlock body={custom.body} />
</div>
{/if}
</main>

View File

@@ -0,0 +1,81 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { serverClient } from '$lib/server/sanity';
import type { Blog } from '$lib/sanity.types';
const BLOGS_PER_PAGE = 12;
const BLOGS_QUERY = `*[_type == "blog" && defined(slug.current)] | order(publishedAt desc, _createdAt desc)[$start...$end]{
_id,
_type,
_createdAt,
_updatedAt,
title,
slug,
author,
publishedAt,
tags,
excerpt,
mainImage,
body[0...2]
}`;
const TOTAL_BLOGS_QUERY = `count(*[_type == "blog" && defined(slug.current)])`;
export const GET: RequestHandler = async ({ url }) => {
try {
const page = parseInt(url.searchParams.get('page') || '1', 10);
const start = (page - 1) * BLOGS_PER_PAGE;
const end = start + BLOGS_PER_PAGE;
const [blogs, totalBlogs]: [Blog[], number] = await Promise.all([
serverClient.fetch(BLOGS_QUERY, { start, end }),
serverClient.fetch(TOTAL_BLOGS_QUERY)
]);
const totalPages = Math.ceil(totalBlogs / BLOGS_PER_PAGE);
// Process blogs to extract descriptions if excerpt doesn't exist
const processedBlogs = blogs.map((blog) => {
let description = blog.excerpt;
if (!description && blog.body && Array.isArray(blog.body)) {
const firstBlock = blog.body[0];
if (
firstBlock &&
firstBlock._type === 'block' &&
'children' in firstBlock &&
Array.isArray(firstBlock.children)
) {
description = firstBlock.children
.filter((child: any) => child.text)
.map((child: any) => child.text)
.join(' ')
.slice(0, 160);
}
}
return {
...blog,
description
};
});
return json({
blogs: processedBlogs,
pagination: {
currentPage: page,
totalPages,
totalBlogs,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1
}
});
} catch (error) {
console.error('Error fetching blogs via API:', error);
return json(
{ error: 'Failed to fetch blogs' },
{ status: 500 }
);
}
};

View File

@@ -0,0 +1,95 @@
import type { PageServerLoad } from './$types';
import { serverClient } from '$lib/server/sanity';
import type { Blog } from '$lib/sanity.types';
const BLOGS_PER_PAGE = 12;
const BLOGS_QUERY = `*[_type == "blog" && defined(slug.current)] | order(publishedAt desc, _createdAt desc)[$start...$end]{
_id,
_type,
_createdAt,
_updatedAt,
title,
slug,
author,
publishedAt,
tags,
excerpt,
mainImage,
body[0...2]
}`;
const TOTAL_BLOGS_QUERY = `count(*[_type == "blog" && defined(slug.current)])`;
export const load: PageServerLoad = async ({ url, depends }) => {
depends('blog:pagination', url.searchParams.toString());
try {
const page = parseInt(url.searchParams.get('page') || '1', 10);
const start = (page - 1) * BLOGS_PER_PAGE;
const end = start + BLOGS_PER_PAGE;
const [blogs, totalBlogs]: [Blog[], number] = await Promise.all([
serverClient.fetch(BLOGS_QUERY, { start, end }),
serverClient.fetch(TOTAL_BLOGS_QUERY)
]);
const totalPages = Math.ceil(totalBlogs / BLOGS_PER_PAGE);
// Process blogs to extract descriptions if excerpt doesn't exist
const processedBlogs = blogs.map((blog) => {
let description = blog.excerpt;
if (!description && blog.body && Array.isArray(blog.body)) {
const firstBlock = blog.body[0];
if (
firstBlock &&
firstBlock._type === 'block' &&
'children' in firstBlock &&
Array.isArray(firstBlock.children)
) {
description = firstBlock.children
.filter((child: any) => child.text)
.map((child: any) => child.text)
.join(' ')
.slice(0, 160);
}
}
return {
...blog,
description
};
});
return {
blogs: processedBlogs,
pagination: {
currentPage: page,
totalPages,
totalBlogs,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1
},
meta: {
title: 'Blog',
description: 'Read our latest blog posts and articles.'
}
};
} catch (error) {
console.error('Error fetching blogs:', error);
return {
blogs: [],
pagination: {
currentPage: 1,
totalPages: 0,
totalBlogs: 0,
hasNextPage: false,
hasPreviousPage: false
},
meta: {
title: 'Blog',
description: 'Read our latest blog posts and articles.'
}
};
}
};

View File

@@ -0,0 +1,248 @@
<script lang="ts">
import type { PageData } from './$types';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import CoverImage from '$lib/components/cover-image.svelte';
import { formatDate } from '$lib/utils';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
let { data }: { data: PageData } = $props();
const { blogs, pagination, meta } = data;
function createPageUrl(pageNum: number): string {
const url = new URL($page.url);
if (pageNum === 1) {
url.searchParams.delete('page');
} else {
url.searchParams.set('page', pageNum.toString());
}
return url.pathname + url.search;
}
function getVisiblePages(): (number | 'ellipsis')[] {
const { currentPage, totalPages } = pagination;
const pages: (number | 'ellipsis')[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
if (currentPage <= 4) {
for (let i = 2; i <= 5; i++) {
pages.push(i);
}
pages.push('ellipsis');
pages.push(totalPages);
} else if (currentPage >= totalPages - 3) {
pages.push('ellipsis');
for (let i = totalPages - 4; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push('ellipsis');
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push('ellipsis');
pages.push(totalPages);
}
}
return pages;
}
function navigateToPost(slug: string | undefined) {
if (slug) {
goto(`/blog/${slug}`);
}
}
</script>
<svelte:head>
<title>{meta.title}</title>
<meta name="description" content={meta.description} />
<meta property="og:title" content={meta.title} />
<meta property="og:description" content={meta.description} />
<meta property="og:type" content="website" />
<meta property="og:url" content="/blog" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={meta.title} />
<meta name="twitter:description" content={meta.description} />
</svelte:head>
<main class="container mx-auto min-h-screen max-w-6xl p-8">
<header class="mb-12">
<h1 class="mb-4 font-serif text-3xl font-bold sm:text-4xl md:text-5xl">Blog</h1>
<p class="text-lg text-muted-foreground">{meta.description}</p>
{#if pagination.totalBlogs > 0}
<p class="mt-2 text-sm text-muted-foreground">
Showing {blogs.length} of {pagination.totalBlogs} posts
</p>
{/if}
</header>
{#if blogs.length === 0}
<div class="py-16 text-center">
<p class="text-lg text-muted-foreground">No blog posts found.</p>
</div>
{:else}
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each blogs as blog}
{@const publishedDate = blog.publishedAt
? new Date(blog.publishedAt)
: new Date(blog._createdAt)}
{@const formattedDate = formatDate(publishedDate)}
{@const extendedBlog = blog as any}
<Card
class="group cursor-pointer overflow-hidden transition-all focus-within:ring-2 focus-within:ring-primary/20 hover:shadow-md"
onclick={() => navigateToPost(blog.slug?.current)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
navigateToPost(blog.slug?.current);
}
}}
tabindex={0}
role="button"
aria-label="Read blog post: {blog.title}"
>
{#if blog.mainImage?.asset}
<div
class="aspect-video overflow-hidden [&>img]:transition-transform [&>img]:duration-300 group-hover:[&>img]:scale-105"
>
<CoverImage
image={blog.mainImage}
alt={blog.mainImage.alt || blog.title || 'Blog cover image'}
width={800}
height={450}
/>
</div>
{:else}
<div class="flex aspect-video items-center justify-center bg-muted">
<div class="text-center">
<div class="mb-2 text-4xl text-muted-foreground">📝</div>
<p class="text-sm text-muted-foreground">No cover image</p>
</div>
</div>
{/if}
<CardHeader class="pb-3">
<CardTitle
class="font-serif text-xl leading-tight transition-colors group-hover:text-primary"
>
{blog.title}
</CardTitle>
<div class="flex flex-col gap-1 text-sm text-muted-foreground">
{#if blog.author}
<div class="flex items-center gap-2">
<span class="font-medium">By {blog.author}</span>
</div>
{/if}
<div class="flex items-center gap-2">
<span>{formattedDate}</span>
</div>
</div>
{#if blog.tags && blog.tags.length > 0}
<div class="mt-3 flex flex-wrap gap-1">
{#each blog.tags.slice(0, 3) as tag}
<span
class="inline-block rounded bg-muted px-2 py-1 text-xs font-medium text-muted-foreground"
>
{tag}
</span>
{/each}
{#if blog.tags.length > 3}
<span class="text-xs text-muted-foreground">+{blog.tags.length - 3} more</span>
{/if}
</div>
{/if}
</CardHeader>
<CardContent class="pt-0">
{#if blog.excerpt || extendedBlog.description}
<p class="mb-4 line-clamp-3 text-sm leading-relaxed text-muted-foreground">
{blog.excerpt || extendedBlog.description}
</p>
{/if}
<div
class="inline-flex items-center text-sm font-medium text-primary transition-colors group-hover:text-primary/80"
>
Read more
<svg class="ml-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
></path>
</svg>
</div>
</CardContent>
</Card>
{/each}
</div>
{#if pagination.totalPages > 1}
<nav class="mt-12 flex justify-center" aria-label="Pagination">
<div class="flex items-center gap-1">
{#if pagination.hasPreviousPage}
<a href={createPageUrl(pagination.currentPage - 1)} data-sveltekit-reload>
<Button variant="outline" size="sm">
<svg class="mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
></path>
</svg>
Previous
</Button>
</a>
{/if}
{#each getVisiblePages() as pageItem}
{#if pageItem === 'ellipsis'}
<span class="px-3 py-2 text-muted-foreground">...</span>
{:else if pageItem === pagination.currentPage}
<Button variant="default" size="sm" disabled>
{pageItem}
</Button>
{:else}
<a href={createPageUrl(pageItem)} data-sveltekit-reload>
<Button variant="ghost" size="sm">
{pageItem}
</Button>
</a>
{/if}
{/each}
{#if pagination.hasNextPage}
<a href={createPageUrl(pagination.currentPage + 1)} data-sveltekit-reload>
<Button variant="outline" size="sm">
Next
<svg class="ml-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
></path>
</svg>
</Button>
</a>
{/if}
</div>
</nav>
{/if}
{/if}
</main>

View File

@@ -0,0 +1,3 @@
export const ssr = true;
export const csr = true;
export const prerender = false;

View File

@@ -0,0 +1,69 @@
import type { PageServerLoad } from './$types';
import { serverClient } from '$lib/server/sanity';
import type { Blog } from '$lib/sanity.types';
import { error } from '@sveltejs/kit';
const BLOG_QUERY = `*[_type == "blog" && slug.current == $slug][0]{
_id,
_type,
_createdAt,
_updatedAt,
_rev,
title,
slug,
author,
publishedAt,
tags,
body,
excerpt,
mainImage
}`;
export const load: PageServerLoad = async ({ params }) => {
const { slug } = params;
if (!slug) {
throw error(404, 'Slug not found');
}
try {
const blog: Blog = await serverClient.fetch(BLOG_QUERY, { slug });
if (!blog) {
throw error(404, 'Blog post not found');
}
// Extract description from first block if no excerpt exists
let description = blog.excerpt;
if (!description && blog.body && Array.isArray(blog.body)) {
const firstBlock = blog.body[0];
if (
firstBlock &&
firstBlock._type === 'block' &&
'children' in firstBlock &&
Array.isArray(firstBlock.children)
) {
description = firstBlock.children
.filter((child: any) => child.text)
.map((child: any) => child.text)
.join(' ')
.slice(0, 160);
}
}
return {
blog,
meta: {
title: blog.title,
description,
url: `/blog/${blog.slug?.current}`,
publishedAt: blog.publishedAt,
author: blog.author,
tags: blog.tags
}
};
} catch (err) {
console.error('Error fetching blog post:', err);
throw error(500, 'Failed to load blog post');
}
};

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import type { PageData } from './$types';
import SanityBlock from '$lib/components/sanity-block.svelte';
import CoverImage from '$lib/components/cover-image.svelte';
import { formatDate } from '$lib/utils';
let { data }: { data: PageData } = $props();
const { blog, meta } = data;
const publishedDate = blog.publishedAt ? new Date(blog.publishedAt) : new Date(blog._createdAt);
const formattedDate = formatDate ? formatDate(publishedDate) : publishedDate.toLocaleDateString();
</script>
<svelte:head>
<title>{meta.title || blog.title || 'Blog Post'}</title>
<meta name="description" content={meta.description || ''} />
<meta property="og:title" content={meta.title || blog.title || ''} />
<meta property="og:description" content={meta.description || ''} />
<meta property="og:type" content="article" />
<meta property="og:url" content={meta.url || ''} />
{#if meta.publishedAt}
<meta property="article:published_time" content={meta.publishedAt} />
{/if}
{#if meta.author}
<meta property="article:author" content={meta.author} />
{/if}
{#if meta.tags}
{#each meta.tags as tag}
<meta property="article:tag" content={tag} />
{/each}
{/if}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={meta.title || blog.title || ''} />
<meta name="twitter:description" content={meta.description || ''} />
</svelte:head>
<main class="container mx-auto flex min-h-screen max-w-3xl flex-col gap-4 p-8 md:max-w-4xl">
<article>
{#if blog.mainImage?.asset}
<div class="-mx-8 mb-8 overflow-hidden md:mx-0 md:rounded-lg">
<div class="h-64 w-full md:h-96">
<CoverImage
image={blog.mainImage}
alt={blog.mainImage.alt || blog.title || 'Blog cover image'}
width={1200}
height={600}
/>
</div>
</div>
{/if}
<header class="mb-8">
<h1 class="mb-4 font-serif text-2xl font-bold sm:text-3xl md:text-4xl">
{blog.title}
</h1>
<div
class="mb-6 flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:gap-4"
>
{#if blog.author}
<span>By {blog.author}</span>
{/if}
<span>{formattedDate}</span>
</div>
{#if blog.tags && blog.tags.length > 0}
<div class="mb-6 flex flex-wrap gap-2">
{#each blog.tags as tag}
<span
class="inline-block rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground"
>
{tag}
</span>
{/each}
</div>
{/if}
</header>
{#if blog.body}
<div class="prose prose-lg max-w-none">
<SanityBlock body={blog.body} />
</div>
{/if}
</article>
</main>

View File

@@ -0,0 +1,3 @@
export const ssr = true;
export const csr = true;
export const prerender = false;

View File

@@ -1,4 +1,4 @@
{
"generates": "../client-svelte/src/lib/sanity.types.ts",
"path": "./schemaTypes/*.ts"
}
"generates": "../client/src/lib/sanity.types.ts",
"path": "./schemaTypes/*.ts"
}

View File

@@ -1,6 +1,6 @@
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {ClipboardIcon, HomeIcon, MenuIcon, WrenchIcon} from '@sanity/icons'
import {ClipboardIcon, EditIcon, HomeIcon, MenuIcon, WrenchIcon} from '@sanity/icons'
import {schemaTypes} from './schemaTypes'
import {presentationTool} from 'sanity/presentation'
import {linkField} from 'sanity-plugin-link-field'
@@ -41,11 +41,36 @@ export default defineConfig({
.title('Custom pages')
.icon(ClipboardIcon)
.child(S.documentTypeList('custom').title('Content')),
S.listItem()
.title('Blog')
.icon(EditIcon)
.child(S.documentTypeList('blog').title('Blog')),
]),
}),
// @ts-ignore
linkField({
linkableSchemaTypes: ['custom'],
linkableSchemaTypes: ['custom', 'blog'],
customLinkTypes: [
{
title: 'Static pages',
value: 'static',
icon: ClipboardIcon,
options: [
{
title: 'Home',
value: '/',
},
{
title: 'Second page',
value: '/second',
},
{
title: 'Blog',
value: '/blog',
},
],
},
],
}),
presentationTool({
previewUrl: {

View File

@@ -41,6 +41,13 @@
},
"optional": true
},
"publishedAt": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"headerSection": {
"type": "objectAttribute",
"value": {
@@ -51,6 +58,524 @@
}
}
},
{
"name": "blog",
"type": "document",
"attributes": {
"_id": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "blog"
}
},
"_createdAt": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_updatedAt": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_rev": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"title": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"slug": {
"type": "objectAttribute",
"value": {
"type": "inline",
"name": "slug"
},
"optional": true
},
"author": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"publishedAt": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"tags": {
"type": "objectAttribute",
"value": {
"type": "array",
"of": {
"type": "string"
}
},
"optional": true
},
"excerpt": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"mainImage": {
"type": "objectAttribute",
"value": {
"type": "object",
"attributes": {
"asset": {
"type": "objectAttribute",
"value": {
"type": "object",
"attributes": {
"_ref": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "reference"
}
},
"_weak": {
"type": "objectAttribute",
"value": {
"type": "boolean"
},
"optional": true
}
},
"dereferencesTo": "sanity.imageAsset"
},
"optional": true
},
"media": {
"type": "objectAttribute",
"value": {
"type": "unknown"
},
"optional": true
},
"hotspot": {
"type": "objectAttribute",
"value": {
"type": "inline",
"name": "sanity.imageHotspot"
},
"optional": true
},
"crop": {
"type": "objectAttribute",
"value": {
"type": "inline",
"name": "sanity.imageCrop"
},
"optional": true
},
"alt": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "imageWithAlt"
}
}
}
},
"optional": true
},
"body": {
"type": "objectAttribute",
"value": {
"type": "array",
"of": {
"type": "union",
"of": [
{
"type": "object",
"attributes": {
"children": {
"type": "objectAttribute",
"value": {
"type": "array",
"of": {
"type": "object",
"attributes": {
"marks": {
"type": "objectAttribute",
"value": {
"type": "array",
"of": {
"type": "string"
}
},
"optional": true
},
"text": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "span"
}
}
},
"rest": {
"type": "object",
"attributes": {
"_key": {
"type": "objectAttribute",
"value": {
"type": "string"
}
}
}
}
}
},
"optional": true
},
"style": {
"type": "objectAttribute",
"value": {
"type": "union",
"of": [
{
"type": "string",
"value": "normal"
},
{
"type": "string",
"value": "h1"
},
{
"type": "string",
"value": "h2"
},
{
"type": "string",
"value": "h3"
},
{
"type": "string",
"value": "h4"
},
{
"type": "string",
"value": "blockquote"
}
]
},
"optional": true
},
"listItem": {
"type": "objectAttribute",
"value": {
"type": "union",
"of": [
{
"type": "string",
"value": "bullet"
}
]
},
"optional": true
},
"markDefs": {
"type": "objectAttribute",
"value": {
"type": "array",
"of": {
"type": "union",
"of": [
{
"type": "object",
"attributes": {
"href": {
"type": "objectAttribute",
"value": {
"type": "inline",
"name": "link"
},
"optional": true
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "link"
}
}
},
"rest": {
"type": "object",
"attributes": {
"_key": {
"type": "objectAttribute",
"value": {
"type": "string"
}
}
}
}
},
{
"type": "object",
"attributes": {
"_key": {
"type": "objectAttribute",
"value": {
"type": "string"
}
}
},
"rest": {
"type": "inline",
"name": "textColor"
}
},
{
"type": "object",
"attributes": {
"_key": {
"type": "objectAttribute",
"value": {
"type": "string"
}
}
},
"rest": {
"type": "inline",
"name": "highlightColor"
}
}
]
}
},
"optional": true
},
"level": {
"type": "objectAttribute",
"value": {
"type": "number"
},
"optional": true
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "block"
}
}
},
"rest": {
"type": "object",
"attributes": {
"_key": {
"type": "objectAttribute",
"value": {
"type": "string"
}
}
}
}
},
{
"type": "object",
"attributes": {
"_key": {
"type": "objectAttribute",
"value": {
"type": "string"
}
}
},
"rest": {
"type": "inline",
"name": "button"
}
},
{
"type": "object",
"attributes": {
"asset": {
"type": "objectAttribute",
"value": {
"type": "object",
"attributes": {
"_ref": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "reference"
}
},
"_weak": {
"type": "objectAttribute",
"value": {
"type": "boolean"
},
"optional": true
}
},
"dereferencesTo": "sanity.imageAsset"
},
"optional": true
},
"media": {
"type": "objectAttribute",
"value": {
"type": "unknown"
},
"optional": true
},
"hotspot": {
"type": "objectAttribute",
"value": {
"type": "inline",
"name": "sanity.imageHotspot"
},
"optional": true
},
"crop": {
"type": "objectAttribute",
"value": {
"type": "inline",
"name": "sanity.imageCrop"
},
"optional": true
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "image"
}
}
},
"rest": {
"type": "object",
"attributes": {
"_key": {
"type": "objectAttribute",
"value": {
"type": "string"
}
}
}
}
},
{
"type": "object",
"attributes": {
"asset": {
"type": "objectAttribute",
"value": {
"type": "object",
"attributes": {
"_ref": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "reference"
}
},
"_weak": {
"type": "objectAttribute",
"value": {
"type": "boolean"
},
"optional": true
}
},
"dereferencesTo": "sanity.fileAsset"
},
"optional": true
},
"media": {
"type": "objectAttribute",
"value": {
"type": "unknown"
},
"optional": true
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "file"
}
}
},
"rest": {
"type": "object",
"attributes": {
"_key": {
"type": "objectAttribute",
"value": {
"type": "string"
}
}
}
}
}
]
}
},
"optional": true
}
}
},
{
"name": "faqSection",
"type": "type",
@@ -1151,6 +1676,270 @@
}
}
},
{
"name": "navbar",
"type": "document",
"attributes": {
"_id": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "navbar"
}
},
"_createdAt": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_updatedAt": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_rev": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"title": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"links": {
"type": "objectAttribute",
"value": {
"type": "array",
"of": {
"type": "object",
"attributes": {
"text": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"link": {
"type": "objectAttribute",
"value": {
"type": "inline",
"name": "link"
},
"optional": true
},
"sublinks": {
"type": "objectAttribute",
"value": {
"type": "array",
"of": {
"type": "object",
"attributes": {
"type": {
"type": "objectAttribute",
"value": {
"type": "union",
"of": [
{
"type": "string",
"value": "auto"
},
{
"type": "string",
"value": "tag"
},
{
"type": "string",
"value": "manual"
}
]
},
"optional": true
},
"pageType": {
"type": "objectAttribute",
"value": {
"type": "union",
"of": [
{
"type": "string",
"value": "custom"
},
{
"type": "string",
"value": "blog"
}
]
},
"optional": true
},
"text": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"link": {
"type": "objectAttribute",
"value": {
"type": "inline",
"name": "link"
},
"optional": true
},
"tagFilter": {
"type": "objectAttribute",
"value": {
"type": "object",
"attributes": {
"_ref": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "reference"
}
},
"_weak": {
"type": "objectAttribute",
"value": {
"type": "boolean"
},
"optional": true
}
},
"dereferencesTo": "tag"
},
"optional": true
},
"tagPageType": {
"type": "objectAttribute",
"value": {
"type": "union",
"of": [
{
"type": "string",
"value": "custom"
},
{
"type": "string",
"value": "blog"
}
]
},
"optional": true
}
},
"rest": {
"type": "object",
"attributes": {
"_key": {
"type": "objectAttribute",
"value": {
"type": "string"
}
}
}
}
}
},
"optional": true
}
},
"rest": {
"type": "object",
"attributes": {
"_key": {
"type": "objectAttribute",
"value": {
"type": "string"
}
}
}
}
}
},
"optional": true
}
}
},
{
"name": "tag",
"type": "document",
"attributes": {
"_id": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_type": {
"type": "objectAttribute",
"value": {
"type": "string",
"value": "tag"
}
},
"_createdAt": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_updatedAt": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"_rev": {
"type": "objectAttribute",
"value": {
"type": "string"
}
},
"title": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"slug": {
"type": "objectAttribute",
"value": {
"type": "inline",
"name": "slug"
},
"optional": true
},
"description": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
}
}
},
{
"name": "settings",
"type": "document",
@@ -2318,6 +3107,23 @@
},
"optional": true
},
"tags": {
"type": "objectAttribute",
"value": {
"type": "array",
"of": {
"type": "string"
}
},
"optional": true
},
"publishedAt": {
"type": "objectAttribute",
"value": {
"type": "string"
},
"optional": true
},
"body": {
"type": "objectAttribute",
"value": {

View File

@@ -1,250 +1,254 @@
import { LinkIcon, MenuIcon } from "@sanity/icons"
import { defineField, defineType, type Rule, type StringRule } from "sanity"
import { requiredLinkField } from "sanity-plugin-link-field"
import {LinkIcon, MenuIcon} from '@sanity/icons'
import {defineField, defineType, type Rule, type StringRule} from 'sanity'
import {requiredLinkField} from 'sanity-plugin-link-field'
export default defineType({
name: 'navbar',
title: 'Navigation',
type: 'document',
icon: MenuIcon,
fields: [
defineField({
name: 'title',
title: 'Titel',
type: 'string',
initialValue: 'Navigation',
readOnly: true,
hidden: true,
}),
defineField({
name: 'links',
title: 'Links',
icon: LinkIcon,
type: 'array',
initialValue: [
name: 'navbar',
title: 'Navigation',
type: 'document',
icon: MenuIcon,
fields: [
defineField({
name: 'title',
title: 'Titel',
type: 'string',
initialValue: 'Navigation',
readOnly: true,
hidden: true,
}),
defineField({
name: 'links',
title: 'Links',
icon: LinkIcon,
type: 'array',
initialValue: [
{
text: 'Home',
link: {
type: 'url',
value: '/',
},
},
{
text: 'Second Page',
link: {
type: 'url',
value: '/second',
},
},
{
text: 'Blog',
link: {
type: 'url',
value: '/blog',
},
sublinks: [
{
type: 'auto',
pageType: 'blog',
autoTitle: 'Latest Blog Posts',
},
],
},
],
of: [
{
type: 'object',
title: 'Link',
icon: LinkIcon,
fields: [
defineField({
name: 'text',
title: 'Text',
type: 'string',
validation: (Rule: StringRule) => Rule.required().error('Text is required'),
}),
defineField({
name: 'link',
title: 'Link',
type: 'link',
validation: (rule: Rule) => rule.custom((field: Rule) => requiredLinkField(field)),
}),
defineField({
name: 'sublinks',
title: 'Sublinks',
type: 'array',
validation: (Rule) => Rule.max(5).error('Maximum 5 sublinks allowed'),
of: [
{
text: 'Home',
link: {
type: 'url',
value: '/',
}
},
{
text: 'Second Page',
link: {
type: 'url',
value: '/second',
}
},
{
text: 'Blog',
link: {
type: 'url',
value: '/blog',
type: 'object',
title: 'Sublink',
icon: LinkIcon,
fields: [
defineField({
name: 'type',
title: 'Link Type',
type: 'string',
initialValue: 'auto',
options: {
list: [
{
title: 'Last Pages',
value: 'auto',
},
{
title: 'Pages by Tag',
value: 'tag',
},
{
title: 'Manual Link',
value: 'manual',
},
],
layout: 'radio',
},
validation: (Rule: StringRule) => Rule.required(),
}),
defineField({
name: 'pageType',
title: 'Page Type',
type: 'string',
initialValue: 'custom',
description:
'Automatically displays the 5 most recently published pages from the selected type',
options: {
list: [
{title: 'Custom Pages', value: 'custom'},
{title: 'Blog Posts', value: 'blog'},
],
},
hidden: ({parent}) => parent?.type !== 'auto',
validation: (Rule: StringRule) =>
Rule.custom((value, context) => {
const parent = context.parent as {type?: string}
if (parent?.type === 'auto' && !value) {
return 'A page type must be selected'
}
return true
}),
}),
defineField({
name: 'text',
title: 'Text',
type: 'string',
hidden: ({parent}) => parent?.type !== 'manual',
validation: (Rule: StringRule) =>
Rule.custom((value, context) => {
const parent = context.parent as {type?: string}
if (parent?.type === 'manual' && !value) {
return 'Text is required for manual links'
}
return true
}),
}),
defineField({
name: 'link',
title: 'Link',
type: 'link',
hidden: ({parent}) => parent?.type !== 'manual',
validation: (rule: Rule) =>
rule.custom((field, context) => {
const parent = context.parent as {type?: string}
if (parent?.type === 'manual') {
return requiredLinkField(field)
}
return true
}),
}),
defineField({
name: 'tagFilter',
title: 'Tag Filter',
type: 'reference',
to: [{type: 'tag'}],
description:
'Select a tag to filter pages by. The last 5 published pages with this tag will be shown.',
hidden: ({parent}) => parent?.type !== 'tag',
validation: (Rule) =>
Rule.custom((value, context) => {
const parent = context.parent as {type?: string}
if (parent?.type === 'tag' && !value) {
return 'A tag is required when using tag-based filtering'
}
return true
}),
}),
defineField({
name: 'tagPageType',
title: 'Page Type for Tag Filter',
type: 'string',
description: 'Select which type of pages to search for the tag',
options: {
list: [
{title: 'Custom Pages', value: 'custom'},
{title: 'Blog Posts', value: 'blog'},
],
},
hidden: ({parent}) => parent?.type !== 'tag',
initialValue: 'custom',
validation: (Rule: StringRule) =>
Rule.custom((value, context) => {
const parent = context.parent as {type?: string}
if (parent?.type === 'tag' && !value) {
return 'A page type must be selected for tag filtering'
}
return true
}),
}),
],
preview: {
select: {
title: 'text',
type: 'type',
tagTitles: 'tagTitles',
autoTitle: 'autoTitle',
pageType: 'pageType',
tagPageType: 'tagPageType',
},
sublinks: [
{
type: 'auto',
pageType: 'blog',
autoTitle: 'Latest Blog Posts'
prepare({title, type, tagTitles, autoTitle, pageType, tagPageType}) {
let displayTitle = title
let subtitle = ''
const formatPageType = (pageType: string) => {
const typeMap: Record<string, string> = {
home: 'Home',
custom: 'Custom',
blog: 'Blog',
}
]
}
],
of: [
{
type: 'object',
title: 'Link',
icon: LinkIcon,
fields: [
defineField({
name: 'text',
title: 'Text',
type: 'string',
validation: (Rule: StringRule) => Rule.required().error('Text is required'),
}),
defineField({
name: 'link',
title: 'Link',
type: 'link',
validation: (rule: Rule) => rule.custom((field: Rule) => requiredLinkField(field))
}),
defineField({
name: 'sublinks',
title: 'Sublinks',
type: 'array',
validation: (Rule) => Rule.max(5).error('Maximum 5 sublinks allowed'),
of: [
{
type: 'object',
title: 'Sublink',
icon: LinkIcon,
fields: [
defineField({
name: 'type',
title: 'Link Type',
type: 'string',
initialValue: 'auto',
options: {
list: [
{
title: 'Last Pages',
value: 'auto'
},
{
title: 'Pages by Tag',
value: 'tag'
},
{
title: 'Manual Link',
value: 'manual'
},
],
layout: 'radio',
},
validation: (Rule: StringRule) => Rule.required(),
}),
defineField({
name: 'pageType',
title: 'Page Type',
type: 'string',
initialValue: 'custom',
description: 'Automatically displays the 5 most recently published pages from the selected type',
options: {
list: [
{ title: 'Custom Pages', value: 'custom' },
{ title: 'Blog Posts', value: 'blog' },
],
},
hidden: ({ parent }) => parent?.type !== 'auto',
validation: (Rule: StringRule) =>
Rule.custom((value, context) => {
const parent = context.parent as { type?: string }
if (parent?.type === 'auto' && !value) {
return 'A page type must be selected'
}
return true
}),
}),
defineField({
name: 'text',
title: 'Text',
type: 'string',
hidden: ({ parent }) => parent?.type !== 'manual',
validation: (Rule: StringRule) =>
Rule.custom((value, context) => {
const parent = context.parent as { type?: string }
if (parent?.type === 'manual' && !value) {
return 'Text is required for manual links'
}
return true
}),
}),
defineField({
name: 'link',
title: 'Link',
type: 'link',
hidden: ({ parent }) => parent?.type !== 'manual',
validation: (rule: Rule) =>
rule.custom((field, context) => {
const parent = context.parent as { type?: string }
if (parent?.type === 'manual') {
return requiredLinkField(field)
}
return true
}),
}),
defineField({
name: 'tagFilter',
title: 'Tag Filter',
type: 'reference',
to: [{ type: 'tag' }],
description: 'Select a tag to filter pages by. The last 5 published pages with this tag will be shown.',
hidden: ({ parent }) => parent?.type !== 'tag',
validation: (Rule) =>
Rule.custom((value, context) => {
const parent = context.parent as { type?: string }
if (parent?.type === 'tag' && (!value)) {
return 'A tag is required when using tag-based filtering'
}
return true
}),
}),
defineField({
name: 'tagPageType',
title: 'Page Type for Tag Filter',
type: 'string',
description: 'Select which type of pages to search for the tag',
options: {
list: [
{ title: 'Custom Pages', value: 'custom' },
{ title: 'Blog Posts', value: 'blog' },
],
},
hidden: ({ parent }) => parent?.type !== 'tag',
initialValue: 'custom',
validation: (Rule: StringRule) =>
Rule.custom((value, context) => {
const parent = context.parent as { type?: string }
if (parent?.type === 'tag' && !value) {
return 'A page type must be selected for tag filtering'
}
return true
}),
}),
],
preview: {
select: {
title: 'text',
type: 'type',
tagTitles: 'tagTitles',
autoTitle: 'autoTitle',
pageType: 'pageType',
tagPageType: 'tagPageType',
},
prepare({ title, type, tagTitles, autoTitle, pageType, tagPageType }) {
let displayTitle = title
let subtitle = ''
const formatPageType = (pageType: string) => {
const typeMap: Record<string, string> = {
home: 'Home',
custom: 'Custom',
blog: 'Blog'
}
return typeMap[pageType] || pageType.charAt(0).toUpperCase() + pageType.slice(1)
}
switch (type) {
case 'auto':
displayTitle = autoTitle || 'Last Pages'
subtitle = `Auto: Latest 5 from ${formatPageType(pageType)}`
break
case 'tag':
const tagNames = tagTitles?.filter(Boolean).join(', ') || 'No tags'
displayTitle = autoTitle || `Pages tagged: ${tagNames}`
subtitle = `Tag: "${tagNames}" in ${formatPageType(tagPageType)}`
break
case 'manual':
subtitle = 'Manual link'
break
default:
displayTitle = 'Unconfigured link'
}
return {
title: displayTitle || 'Untitled Sublink',
subtitle,
media: LinkIcon,
}
},
},
},
],
}),
],
return (
typeMap[pageType] || pageType.charAt(0).toUpperCase() + pageType.slice(1)
)
}
switch (type) {
case 'auto':
displayTitle = autoTitle || 'Last Pages'
subtitle = `Auto: Latest 5 from ${formatPageType(pageType)}`
break
case 'tag':
const tagNames = tagTitles?.filter(Boolean).join(', ') || 'No tags'
displayTitle = autoTitle || `Pages tagged: ${tagNames}`
subtitle = `Tag: "${tagNames}" in ${formatPageType(tagPageType)}`
break
case 'manual':
subtitle = 'Manual link'
break
default:
displayTitle = 'Unconfigured link'
}
return {
title: displayTitle || 'Untitled Sublink',
subtitle,
media: LinkIcon,
}
},
},
},
],
}),
],
})
],
}),
],
},
],
}),
],
})

View File

@@ -1,5 +1,5 @@
import { DocumentIcon } from '@sanity/icons'
import { defineField, defineType, type SlugRule, type StringRule } from 'sanity'
import {DocumentIcon} from '@sanity/icons'
import {defineField, defineType, type SlugRule, type StringRule} from 'sanity'
export default defineType({
name: 'blog',
@@ -11,7 +11,7 @@ export default defineType({
name: 'title',
title: 'Title',
type: 'string',
validation: (Rule: StringRule) => Rule.required().error('Title is required')
validation: (Rule: StringRule) => Rule.required().error('Title is required'),
}),
defineField({
name: 'slug',
@@ -34,14 +34,10 @@ export default defineType({
type: 'datetime',
}),
defineField({
name: 'tags',
title: 'Tags',
type: 'array',
of: [{ type: 'string' }],
description: 'Add tags to categorize this post. Tags can be used to filter and group related content in navigation menus.',
options: {
layout: 'tags',
},
name: 'tagFilter',
title: 'Tag Filter',
type: 'reference',
to: [{type: 'tag'}],
}),
defineField({
name: 'excerpt',
@@ -67,7 +63,7 @@ export default defineType({
author: 'author',
media: 'mainImage',
},
prepare({ title, author, media }) {
prepare({title, author, media}) {
return {
title: title || 'Untitled',
subtitle: author && `by ${author}`,