finished navbar and blog. todo: finishing touches
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<p class="mb-4 leading-relaxed text-foreground">
|
||||
{@render children()}
|
||||
</p>
|
||||
58
template/apps/client/src/lib/components/cover-image.svelte
Normal file
58
template/apps/client/src/lib/components/cover-image.svelte
Normal 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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
23
template/apps/client/src/lib/components/ui/card/card.svelte
Normal file
23
template/apps/client/src/lib/components/ui/card/card.svelte
Normal 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>
|
||||
25
template/apps/client/src/lib/components/ui/card/index.ts
Normal file
25
template/apps/client/src/lib/components/ui/card/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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;
|
||||
|
||||
182
template/apps/client/src/lib/helper/navigation.ts
Normal file
182
template/apps/client/src/lib/helper/navigation.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
106
template/apps/client/src/lib/utils/client-blog-fetch.ts
Normal file
106
template/apps/client/src/lib/utils/client-blog-fetch.ts
Normal 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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
66
template/apps/client/src/routes/[slug]/+page.server.ts
Normal file
66
template/apps/client/src/routes/[slug]/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
42
template/apps/client/src/routes/[slug]/+page.svelte
Normal file
42
template/apps/client/src/routes/[slug]/+page.svelte
Normal 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>
|
||||
81
template/apps/client/src/routes/api/blogs/+server.ts
Normal file
81
template/apps/client/src/routes/api/blogs/+server.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
};
|
||||
95
template/apps/client/src/routes/blog/+page.server.ts
Normal file
95
template/apps/client/src/routes/blog/+page.server.ts
Normal 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.'
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
248
template/apps/client/src/routes/blog/+page.svelte
Normal file
248
template/apps/client/src/routes/blog/+page.svelte
Normal 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>
|
||||
3
template/apps/client/src/routes/blog/+page.ts
Normal file
3
template/apps/client/src/routes/blog/+page.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const ssr = true;
|
||||
export const csr = true;
|
||||
export const prerender = false;
|
||||
69
template/apps/client/src/routes/blog/[slug]/+page.server.ts
Normal file
69
template/apps/client/src/routes/blog/[slug]/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
86
template/apps/client/src/routes/blog/[slug]/+page.svelte
Normal file
86
template/apps/client/src/routes/blog/[slug]/+page.svelte
Normal 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>
|
||||
3
template/apps/client/src/routes/blog/[slug]/+page.ts
Normal file
3
template/apps/client/src/routes/blog/[slug]/+page.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const ssr = true;
|
||||
export const csr = true;
|
||||
export const prerender = false;
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user