This commit is contained in:
@@ -0,0 +1,151 @@
|
|||||||
|
import { postBySlugQuery, postSlugsQuery, urlFor } from '@/lib/sanity'
|
||||||
|
import { sanityFetch } from '@/lib/sanityFetch'
|
||||||
|
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const revalidate = 60
|
||||||
|
|
||||||
|
type Params = { slug: string }
|
||||||
|
|
||||||
|
type Post = {
|
||||||
|
_id: string
|
||||||
|
title: string
|
||||||
|
slug: { current: string }
|
||||||
|
publishedAt: string
|
||||||
|
excerpt: string | null
|
||||||
|
mainImage: { asset: object; alt?: string } | null
|
||||||
|
body: unknown[]
|
||||||
|
seoTitle: string | null
|
||||||
|
seoDescription: string | null
|
||||||
|
author: { name: string; image?: object; bio?: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateStaticParams() {
|
||||||
|
try {
|
||||||
|
const slugs: { slug: string }[] = await sanityFetch<{ slug: string }[]>(postSlugsQuery)
|
||||||
|
return slugs.map((s) => ({ slug: s.slug }))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Guides] generateStaticParams fetch failed:', err)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||||
|
const { slug } = await params
|
||||||
|
let post: Post | null = null
|
||||||
|
try {
|
||||||
|
post = await sanityFetch<Post | null>(postBySlugQuery, { slug })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Guides] generateMetadata fetch failed:', err)
|
||||||
|
}
|
||||||
|
if (!post) return {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: post.seoTitle ?? post.title,
|
||||||
|
description: post.seoDescription ?? post.excerpt ?? undefined,
|
||||||
|
openGraph: {
|
||||||
|
title: post.seoTitle ?? post.title,
|
||||||
|
description: post.seoDescription ?? post.excerpt ?? undefined,
|
||||||
|
images: post.mainImage?.asset
|
||||||
|
? [{ url: urlFor(post.mainImage).width(1200).height(630).url() }]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PostPage({ params }: { params: Promise<Params> }) {
|
||||||
|
const { slug } = await params
|
||||||
|
let post: Post | null = null
|
||||||
|
try {
|
||||||
|
post = await sanityFetch<Post | null>(postBySlugQuery, { slug })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Guides] PostPage fetch failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!post) notFound()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav className="mb-6 text-xs text-zinc-500">
|
||||||
|
<Link href="/guides" className="hover:text-zinc-300">
|
||||||
|
← Guides
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<header className="mb-8">
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-[0.7rem] text-zinc-500">
|
||||||
|
{post.author?.name && <span>{post.author.name}</span>}
|
||||||
|
{post.author?.name && <span>·</span>}
|
||||||
|
<time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-semibold leading-tight tracking-tight text-zinc-100 md:text-4xl">
|
||||||
|
{post.title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{post.excerpt && (
|
||||||
|
<p className="mt-4 text-base text-zinc-400 leading-relaxed">
|
||||||
|
{post.excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Hero image */}
|
||||||
|
{post.mainImage?.asset && (
|
||||||
|
<div className="relative mb-10 h-64 w-full overflow-hidden rounded-xl md:h-80">
|
||||||
|
<Image
|
||||||
|
src={urlFor(post.mainImage).width(1200).height(630).url()}
|
||||||
|
alt={post.mainImage.alt ?? post.title}
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 768px) 100vw, 768px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{post.body && (
|
||||||
|
<article className="border-b border-zinc-800 pb-10">
|
||||||
|
<PortableTextRenderer value={post.body} />
|
||||||
|
</article>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CTA footer */}
|
||||||
|
<div className="mt-10 rounded-xl border border-zinc-800 bg-zinc-950/60 p-6 text-center">
|
||||||
|
<p className="text-sm font-medium text-zinc-200">
|
||||||
|
Ready to put this into practice?
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
Use the Battl Builder to price out your parts and track your total.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/builder"
|
||||||
|
className="mt-4 inline-block rounded-md bg-amber-400 px-5 py-2.5 text-sm font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
Start Your Build →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back link */}
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<Link href="/guides" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||||
|
← Back to all guides
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { postsQuery, urlFor } from '@/lib/sanity'
|
||||||
|
import { sanityFetch } from '@/lib/sanityFetch'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Build Guides',
|
||||||
|
description: 'AR-15 build guides, part breakdowns, and buying advice from Battl Builders.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const revalidate = 60 // ISR — regenerate every 60s
|
||||||
|
|
||||||
|
type Post = {
|
||||||
|
_id: string
|
||||||
|
title: string
|
||||||
|
slug: { current: string }
|
||||||
|
publishedAt: string
|
||||||
|
excerpt: string | null
|
||||||
|
mainImage: { asset: object; alt?: string } | null
|
||||||
|
author: { name: string; image?: object } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GuidesPage() {
|
||||||
|
let posts: Post[] = []
|
||||||
|
try {
|
||||||
|
posts = await sanityFetch<Post[]>(postsQuery)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Guides] Sanity fetch failed:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-4xl px-4 py-10">
|
||||||
|
<header className="mb-10">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">
|
||||||
|
Battl Builders
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-3xl font-semibold tracking-tight">
|
||||||
|
Build Guides
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-sm text-zinc-400 max-w-xl">
|
||||||
|
Part breakdowns, buying advice, and build walkthroughs to help you
|
||||||
|
make smarter decisions before you spend a dollar.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500">No guides published yet — check back soon.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<article
|
||||||
|
key={post._id}
|
||||||
|
className="group flex flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-950/60 p-5 transition-colors hover:border-zinc-700 sm:flex-row"
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
{post.mainImage?.asset && (
|
||||||
|
<div className="relative h-44 w-full shrink-0 overflow-hidden rounded-lg sm:h-32 sm:w-48">
|
||||||
|
<Image
|
||||||
|
src={urlFor(post.mainImage).width(400).height(256).url()}
|
||||||
|
alt={post.mainImage.alt ?? post.title}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
sizes="(max-width: 640px) 100vw, 192px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<div className="flex flex-col justify-center gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-[0.7rem] text-zinc-500">
|
||||||
|
{post.author?.name && <span>{post.author.name}</span>}
|
||||||
|
{post.author?.name && <span>·</span>}
|
||||||
|
<time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-lg font-semibold leading-snug text-zinc-100 group-hover:text-amber-300 transition-colors">
|
||||||
|
<Link href={`/guides/${post.slug.current}`} className="stretched-link">
|
||||||
|
{post.title}
|
||||||
|
</Link>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{post.excerpt && (
|
||||||
|
<p className="text-sm text-zinc-400 leading-relaxed line-clamp-2">
|
||||||
|
{post.excerpt}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/guides/${post.slug.current}`}
|
||||||
|
className="mt-1 self-start text-xs font-medium text-amber-400 hover:text-amber-300"
|
||||||
|
>
|
||||||
|
Read guide →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-12 rounded-lg border border-zinc-800 bg-zinc-950/60 p-5 text-center">
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
Ready to start building?{' '}
|
||||||
|
<Link href="/builder" className="font-semibold text-amber-400 hover:text-amber-300">
|
||||||
|
Open the Builder →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { BuilderNav } from "@/components/BuilderNav";
|
import { BuilderNavConditional } from "@/components/BuilderNavConditional";
|
||||||
import { TopNav } from "@/components/TopNav";
|
import { TopNav } from "@/components/TopNav";
|
||||||
import { Footer } from "@/components/Footer";
|
import { Footer } from "@/components/Footer";
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ export default function BuilderLayout({ children }: { children: ReactNode }) {
|
|||||||
<div className="min-h-screen bg-white text-zinc-900 dark:bg-neutral-950 dark:text-zinc-50">
|
<div className="min-h-screen bg-white text-zinc-900 dark:bg-neutral-950 dark:text-zinc-50">
|
||||||
<TopNav />
|
<TopNav />
|
||||||
<Suspense fallback={<div className="h-[52px]" />}>
|
<Suspense fallback={<div className="h-[52px]" />}>
|
||||||
<BuilderNav />
|
<BuilderNavConditional />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<main className="min-h-screen">{children}</main>
|
<main className="min-h-screen">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
+1
-4
@@ -13,7 +13,7 @@ export default function NotFound() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-3 text-sm text-zinc-400">
|
<p className="mt-3 text-sm text-zinc-400">
|
||||||
This area of the site isn’t publicly accessible yet.
|
The page you're looking for doesn't exist or has been moved.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-center gap-3">
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
@@ -32,9 +32,6 @@ export default function NotFound() {
|
|||||||
</a> */}
|
</a> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-6 text-[11px] text-zinc-600">
|
|
||||||
Early access features are launching soon.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -119,24 +119,15 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
|
|||||||
{/* LEFT SIDE: primary nav */}
|
{/* LEFT SIDE: primary nav */}
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
|
|
||||||
<Link
|
{/* Section label — makes the catalog bar purpose unmistakable */}
|
||||||
href="/builder"
|
<span className="text-[9px] font-bold tracking-[0.2em] uppercase text-zinc-600 select-none pr-3 border-r border-zinc-800">
|
||||||
className="font-semibold text-neutral-100 hover:text-white tracking-[0.25em] uppercase text-[11px]"
|
Parts
|
||||||
>
|
</span>
|
||||||
Builder
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{renderDropdown("Lower Parts", lower)}
|
{renderDropdown("Lower Parts", lower)}
|
||||||
{renderDropdown("Upper Parts", upper)}
|
{renderDropdown("Upper Parts", upper)}
|
||||||
{renderDropdown("Accessories", accessories)}
|
{renderDropdown("Accessories", accessories)}
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/builds"
|
|
||||||
className="font-medium text-neutral-300 hover:text-white tracking-[0.25em] uppercase text-[11px]"
|
|
||||||
>
|
|
||||||
Builds
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
{/* RIGHT SIDE: actions + state */}
|
{/* RIGHT SIDE: actions + state */}
|
||||||
{/* IMPORTANT: this is the ONLY `ml-auto` in the nav to avoid conflicts */}
|
{/* IMPORTANT: this is the ONLY `ml-auto` in the nav to avoid conflicts */}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { BuilderNav } from "./BuilderNav";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps BuilderNav with pathname-aware visibility.
|
||||||
|
* The catalog bar is irrelevant on guide pages — hide it there.
|
||||||
|
*/
|
||||||
|
export function BuilderNavConditional() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Hide catalog bar on guide pages — part categories don't apply when reading an article
|
||||||
|
if (pathname.startsWith("/guides")) return null;
|
||||||
|
|
||||||
|
return <BuilderNav />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BuilderNavConditional;
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { PortableText, type PortableTextComponents } from 'next-sanity'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { urlFor } from '@/lib/sanity'
|
||||||
|
|
||||||
|
const components: PortableTextComponents = {
|
||||||
|
block: {
|
||||||
|
normal: ({ children }) => (
|
||||||
|
<p className="mb-5 leading-relaxed text-zinc-300">{children}</p>
|
||||||
|
),
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 className="mb-4 mt-10 text-3xl font-semibold text-zinc-100">{children}</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="mb-3 mt-8 text-2xl font-semibold text-zinc-100">{children}</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="mb-2 mt-6 text-xl font-semibold text-zinc-100">{children}</h3>
|
||||||
|
),
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="my-6 border-l-2 border-amber-400/60 pl-4 text-zinc-400 italic">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
marks: {
|
||||||
|
strong: ({ children }) => (
|
||||||
|
<strong className="font-semibold text-zinc-100">{children}</strong>
|
||||||
|
),
|
||||||
|
em: ({ children }) => <em className="italic text-zinc-300">{children}</em>,
|
||||||
|
code: ({ children }) => (
|
||||||
|
<code className="rounded bg-zinc-800 px-1.5 py-0.5 font-mono text-sm text-amber-300">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
link: ({ value, children }) => (
|
||||||
|
<a
|
||||||
|
href={value?.href}
|
||||||
|
target={value?.href?.startsWith('http') ? '_blank' : undefined}
|
||||||
|
rel={value?.href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||||
|
className="text-amber-400 underline underline-offset-2 hover:text-amber-300"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
bullet: ({ children }) => (
|
||||||
|
<ul className="mb-5 ml-6 list-disc space-y-1.5 text-zinc-300">{children}</ul>
|
||||||
|
),
|
||||||
|
number: ({ children }) => (
|
||||||
|
<ol className="mb-5 ml-6 list-decimal space-y-1.5 text-zinc-300">{children}</ol>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
listItem: {
|
||||||
|
bullet: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||||
|
number: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||||
|
},
|
||||||
|
types: {
|
||||||
|
image: ({ value }) => {
|
||||||
|
if (!value?.asset) return null
|
||||||
|
return (
|
||||||
|
<figure className="my-8">
|
||||||
|
<div className="relative overflow-hidden rounded-lg">
|
||||||
|
<Image
|
||||||
|
src={urlFor(value).width(800).url()}
|
||||||
|
alt={value.alt ?? ''}
|
||||||
|
width={800}
|
||||||
|
height={450}
|
||||||
|
className="w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{value.caption && (
|
||||||
|
<figcaption className="mt-2 text-center text-xs text-zinc-500">
|
||||||
|
{value.caption}
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortableTextRenderer({ value }: { value: unknown[] }) {
|
||||||
|
return (
|
||||||
|
<div className="prose-battl">
|
||||||
|
<PortableText value={value as any} components={components} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+12
-3
@@ -18,15 +18,17 @@ function NavLink({
|
|||||||
title?: string;
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const active = pathname === href;
|
const active = pathname === href || pathname.startsWith(href + '/');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
title={title}
|
title={title}
|
||||||
className={[
|
className={[
|
||||||
"text-xs font-medium transition-colors",
|
"relative text-xs font-medium transition-colors pb-0.5",
|
||||||
active ? "text-zinc-100" : "text-zinc-400 hover:text-zinc-100",
|
active
|
||||||
|
? "text-zinc-100 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-amber-400"
|
||||||
|
: "text-zinc-400 hover:text-zinc-100",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -55,6 +57,13 @@ export function TopNav() {
|
|||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Centre: primary nav links */}
|
||||||
|
<nav className="hidden sm:flex items-center gap-5">
|
||||||
|
<NavLink href="/builder">Builder</NavLink>
|
||||||
|
<NavLink href="/guides">Guides</NavLink>
|
||||||
|
<NavLink href="/builds">Community</NavLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Right side actions */}
|
{/* Right side actions */}
|
||||||
<div className="flex items-center justify-end gap-3 sm:gap-4">
|
<div className="flex items-center justify-end gap-3 sm:gap-4">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { createClient } from 'next-sanity'
|
||||||
|
import imageUrlBuilder from '@sanity/image-url'
|
||||||
|
import type { SanityImageSource } from '@sanity/image-url/lib/types/types'
|
||||||
|
|
||||||
|
export const projectId = 'zal102rv'
|
||||||
|
export const dataset = 'production'
|
||||||
|
export const apiVersion = '2024-01-01'
|
||||||
|
|
||||||
|
export const client = createClient({
|
||||||
|
projectId,
|
||||||
|
dataset,
|
||||||
|
apiVersion,
|
||||||
|
useCdn: false, // false = direct API; avoids CDN DNS issues in dev/server
|
||||||
|
})
|
||||||
|
|
||||||
|
const builder = imageUrlBuilder(client)
|
||||||
|
|
||||||
|
export function urlFor(source: SanityImageSource) {
|
||||||
|
return builder.image(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GROQ Queries ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** All published posts for the listing page, newest first */
|
||||||
|
export const postsQuery = `
|
||||||
|
*[_type == "post" && defined(slug.current) && publishedAt <= now()]
|
||||||
|
| order(publishedAt desc) {
|
||||||
|
_id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
publishedAt,
|
||||||
|
excerpt,
|
||||||
|
mainImage { ..., alt },
|
||||||
|
"author": author->{ name, image }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
/** Single post by slug for the detail page */
|
||||||
|
export const postBySlugQuery = `
|
||||||
|
*[_type == "post" && slug.current == $slug][0] {
|
||||||
|
_id,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
publishedAt,
|
||||||
|
excerpt,
|
||||||
|
mainImage { ..., alt },
|
||||||
|
body,
|
||||||
|
seoTitle,
|
||||||
|
seoDescription,
|
||||||
|
"author": author->{ name, image, bio }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
/** All slugs — used for generateStaticParams */
|
||||||
|
export const postSlugsQuery = `
|
||||||
|
*[_type == "post" && defined(slug.current)] { "slug": slug.current }
|
||||||
|
`
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import https from 'https'
|
||||||
|
import { projectId, dataset, apiVersion } from './sanity'
|
||||||
|
|
||||||
|
const SANITY_IP = '34.102.242.91'
|
||||||
|
const SANITY_HOST = `${projectId}.api.sanity.io`
|
||||||
|
|
||||||
|
export function sanityFetch<T = unknown>(
|
||||||
|
query: string,
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
): Promise<T> {
|
||||||
|
let path = `/v${apiVersion}/data/query/${dataset}?query=${encodeURIComponent(query)}`
|
||||||
|
if (params) {
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
path += `&$${key}=${encodeURIComponent(JSON.stringify(value))}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.request(
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
hostname: SANITY_IP,
|
||||||
|
port: 443,
|
||||||
|
path,
|
||||||
|
servername: SANITY_HOST,
|
||||||
|
headers: { Host: SANITY_HOST, Accept: 'application/json', 'User-Agent': 'battl-builders/1.0' },
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let data = ''
|
||||||
|
res.on('data', (c) => (data += c))
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
res.statusCode && res.statusCode >= 400
|
||||||
|
? reject(new Error(`Sanity ${res.statusCode}: ${JSON.stringify(parsed)}`))
|
||||||
|
: resolve(parsed.result as T)
|
||||||
|
} catch {
|
||||||
|
reject(new Error(`Sanity parse error: ${data.slice(0, 200)}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
req.on('error', reject)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
+12
-3
@@ -13,6 +13,7 @@ function isPublicPath(pathname: string) {
|
|||||||
// Public pages
|
// Public pages
|
||||||
if (pathname === "/") return true;
|
if (pathname === "/") return true;
|
||||||
if (pathname === "/login") return true;
|
if (pathname === "/login") return true;
|
||||||
|
if (pathname === "/register") return true;
|
||||||
if (pathname === "/tos") return true;
|
if (pathname === "/tos") return true;
|
||||||
if (pathname === "/privacy") return true;
|
if (pathname === "/privacy") return true;
|
||||||
if (pathname === "/beta") return true;
|
if (pathname === "/beta") return true;
|
||||||
@@ -20,12 +21,19 @@ function isPublicPath(pathname: string) {
|
|||||||
if (pathname.startsWith("/auth")) return true;
|
if (pathname.startsWith("/auth")) return true;
|
||||||
if (pathname.startsWith("/api/auth")) return true;
|
if (pathname.startsWith("/api/auth")) return true;
|
||||||
|
|
||||||
|
// Content / guides — always public, no auth required
|
||||||
|
if (pathname === "/guides") return true;
|
||||||
|
if (pathname.startsWith("/guides/")) return true;
|
||||||
|
|
||||||
// Static / framework assets
|
// Static / framework assets
|
||||||
if (pathname.startsWith("/_next")) return true;
|
if (pathname.startsWith("/_next")) return true;
|
||||||
if (pathname === "/favicon.ico") return true;
|
if (pathname === "/favicon.ico") return true;
|
||||||
if (pathname === "/robots.txt") return true;
|
if (pathname === "/robots.txt") return true;
|
||||||
if (pathname === "/sitemap.xml") return true;
|
if (pathname === "/sitemap.xml") return true;
|
||||||
|
|
||||||
|
// Static images in /public
|
||||||
|
if (pathname.startsWith("/battl/")) return true;
|
||||||
|
|
||||||
// Health / misc APIs you may want public
|
// Health / misc APIs you may want public
|
||||||
if (pathname === "/api/health") return true;
|
if (pathname === "/api/health") return true;
|
||||||
|
|
||||||
@@ -45,11 +53,12 @@ function isProtectedLaunchPath(pathname: string) {
|
|||||||
|
|
||||||
export function middleware(req: NextRequest) {
|
export function middleware(req: NextRequest) {
|
||||||
const { pathname } = req.nextUrl;
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
// Short-circuit: always let public paths through without any token checks.
|
||||||
|
if (isPublicPath(pathname)) return NextResponse.next();
|
||||||
|
|
||||||
const token = req.cookies.get("session_token")?.value;
|
const token = req.cookies.get("session_token")?.value;
|
||||||
const isLoggedIn = Boolean(token);
|
const isLoggedIn = Boolean(token);
|
||||||
console.log("is logged in: " + isLoggedIn);
|
|
||||||
console.log("MW:", req.nextUrl.pathname)
|
|
||||||
|
|
||||||
// IMPORTANT: Use a server-only env var for middleware behavior.
|
// IMPORTANT: Use a server-only env var for middleware behavior.
|
||||||
// Set LAUNCH_ONLY_ROOT=true in your Docker compose.
|
// Set LAUNCH_ONLY_ROOT=true in your Docker compose.
|
||||||
const launchOnlyRoot = process.env.LAUNCH_ONLY_ROOT === "true";
|
const launchOnlyRoot = process.env.LAUNCH_ONLY_ROOT === "true";
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ const nextConfig = {
|
|||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'cdn.sanity.io',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
/* async rewrites() {
|
/* async rewrites() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
Generated
+12761
-154
File diff suppressed because it is too large
Load Diff
@@ -27,9 +27,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@sanity/image-url": "^1.1.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "15.5.11",
|
"next": "15.5.11",
|
||||||
|
"next-sanity": "^9.8.19",
|
||||||
"quill-image-resize-module": "^3.0.0",
|
"quill-image-resize-module": "^3.0.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user