add live + draft

This commit is contained in:
2024-10-27 20:12:54 +01:00
parent ff7910584c
commit e9abd5c6d4
20 changed files with 566 additions and 32 deletions

5
.gitignore vendored
View File

@@ -38,3 +38,8 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Turborepo
.turbo
.vercel

BIN
bun.lockb

Binary file not shown.

20
components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.scss",
"baseColor": "neutral",
"cssVariables": false,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

3
debug.log Normal file
View File

@@ -0,0 +1,3 @@
[1027/133620.512:ERROR:crashpad_client_win.cc(810)] not connected
[1027/150423.695:ERROR:crashpad_client_win.cc(810)] not connected
[1027/150423.912:ERROR:crashpad_client_win.cc(810)] not connected

View File

@@ -3,7 +3,17 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
sassOptions: { sassOptions: {
silenceDeprecations: ['legacy-js-api'], silenceDeprecations: ['legacy-js-api'],
},
images: {
remotePatterns: [
{
hostname: "cdn.sanity.io",
pathname: "/images/**",
protocol: "https",
} }
]
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -8,11 +8,24 @@
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint"
}, },
"packageManager": "bun@1.1.33",
"dependencies": { "dependencies": {
"@sanity/image-url": "^1.0.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.453.0",
"next": "15.0.1", "next": "15.0.1",
"next-sanity": "^9.8.7",
"next-themes": "^0.3.0",
"react": "19.0.0-rc-69d4b800-20241021", "react": "19.0.0-rc-69d4b800-20241021",
"react-dom": "19.0.0-rc-69d4b800-20241021", "react-dom": "19.0.0-rc-69d4b800-20241021",
"sass": "^1.80.4" "react-error-boundary": "^4.1.2",
"sanity": "^3.62.2",
"sass": "^1.80.4",
"sonner": "^1.5.0",
"styled-components": "6",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "typescript": "^5",

88
src/app/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,88 @@
import { defineQuery, PortableText } from "next-sanity";
import imageUrlBuilder from "@sanity/image-url";
import type { SanityImageSource } from "@sanity/image-url/lib/types/types";
import { client, sanityFetch } from "../../sanity/client";
import Link from "next/link";
import Image from "next/image";
import { Post, SanityImageAsset } from "@/sanity/sanity.types";
import urlBuilder from "@sanity/image-url";
import {getImageDimensions} from '@sanity/asset-utils'
const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]`);
const POSTS_QUERY = defineQuery(`*[_type == "post"]{slug}`)
type PageParams = Promise<{
slug: string;
}>
const { projectId, dataset } = client.config();
const urlFor = (source: SanityImageSource) =>
projectId && dataset
? imageUrlBuilder({ projectId, dataset }).image(source)
: null;
export async function generateStaticParams() {
const posts: Post[] = (await sanityFetch({ query: POSTS_QUERY, stega: false, perspective: "published" })).data;
return posts.map((post) => ({ slug: post.slug?.current ?? null })).filter((post) => post.slug);
}
export default async function PostPage(props: { params: PageParams }) {
const { slug } = await props.params;
const post: Post = (await sanityFetch({ query: POST_QUERY, params: { slug } })).data;
if (!post) {
return (
<main>
<p>Post not found</p>
</main>
);
}
const postImageUrl = post.mainImage ? urlFor(post.mainImage)?.width(550).height(310).url() : null;
function PortableImage({ value, isInline }: { value: SanityImageAsset; isInline: boolean }) {
const {width, height} = getImageDimensions(value)
return <Image
src={urlBuilder()
.image(value)
.width(isInline ? 100 : 600)
.fit('max')
.auto('format')
.withOptions({dataset, projectId})
.url()}
width={isInline ? 100 : 600}
height={height}
alt={value.altText || ' '}
loading="lazy"
style={{
display: isInline ? 'inline-block' : 'block',
aspectRatio: width / height,
borderRadius: ".6rem",
border: "1px solid rgba(255, 255, 255, .15)",
}}
/>;
}
return (
<main className="container mx-auto min-h-screen max-w-3xl p-8 flex flex-col gap-4">
<Link href="/" className="hover:underline">
Back to posts
</Link>
{postImageUrl && (
<Image
src={postImageUrl}
alt={`Banner for ${post.title}` }
className="aspect-video rounded-xl"
width="550"
height="310"
/>
)}
<h1 className="text-4xl font-bold mb-8">{post.title}</h1>
<div className="prose">
<p>Published: {new Date(post.publishedAt ?? "").toISOString().substring(0, 10)}</p>
{Array.isArray(post.body) && <PortableText value={post.body} components={ { types: { image: PortableImage } } } />}
</div>
</main>
);
}

View File

@@ -0,0 +1,6 @@
import { client } from '@/sanity/client';
import { defineEnableDraftMode } from 'next-sanity/draft-mode';
export const { GET } = defineEnableDraftMode({
client: client.withConfig({ token: process.env.SANITY_API_READ_TOKEN, stega: { studioUrl: 'https://vaporvee.sanity.studio', enabled: true } }),
})

View File

@@ -1,6 +1,12 @@
import localFont from "next/font/local"; import localFont from "next/font/local";
import "../styles/globals.scss"; import "../styles/globals.scss";
import { draftMode } from "next/headers";
import { VisualEditing } from 'next-sanity'
import { SanityLive } from '@/sanity/client'
import { LiveErrorBoundary } from "./live-error-boundary";
import { Toaster } from "@/components/ui/sonner";
const geistSans = localFont({ const geistSans = localFont({
src: "../fonts/GeistVF.woff", src: "../fonts/GeistVF.woff",
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -12,17 +18,24 @@ const geistMono = localFont({
weight: "100 900", weight: "100 900",
}); });
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const { isEnabled } = await draftMode();
return ( return (
<html lang="en"> <html lang="en">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
{children} {children}
<LiveErrorBoundary>
<SanityLive />
</LiveErrorBoundary>
{isEnabled && <VisualEditing />}
<Toaster />
</body> </body>
</html> </html>
); );

View File

@@ -0,0 +1,24 @@
"use client";
import { useEffect } from "react";
import {
ErrorBoundary as ReactErrorBoundary,
type FallbackProps,
} from "react-error-boundary";
export function LiveErrorBoundary({ children }: { children: React.ReactNode }) {
return (
<ReactErrorBoundary FallbackComponent={Fallback}>
{children}
</ReactErrorBoundary>
);
}
function Fallback({ error }: FallbackProps) {
useEffect(() => {
const msg = "Couldn't connect to Live Content API";
console.error(`${msg}: `, error);
}, [error]);
return null;
}

31
src/app/page.tsx Normal file
View File

@@ -0,0 +1,31 @@
import Link from "next/link";
import { type SanityDocument } from "next-sanity";
import { client } from "@/sanity/client";
const POSTS_QUERY = `*[
_type == "post"
&& defined(slug.current)
]|order(publishedAt desc)[0...12]{_id, title, slug, publishedAt}`;
const options = { next: { revalidate: 30 } };
export default async function IndexPage() {
const posts = await client.fetch<SanityDocument[]>(POSTS_QUERY, {}, options);
return (
<main className="container mx-auto min-h-screen max-w-3xl p-8">
<h1 className="text-4xl font-bold mb-8">Posts</h1>
<ul className="flex flex-col gap-y-4">
{posts.map((post) => (
<li className="hover:underline" key={post._id}>
<Link href={`/${post.slug.current}`}>
<h2 className="text-xl font-semibold">{post.title}</h2>
<p>{new Date(post.publishedAt).toLocaleDateString()}</p>
</Link>
</li>
))}
</ul>
</main>
);
}

View File

@@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-white group-[.toaster]:text-neutral-950 group-[.toaster]:border-neutral-200 group-[.toaster]:shadow-lg dark:group-[.toaster]:bg-neutral-950 dark:group-[.toaster]:text-neutral-50 dark:group-[.toaster]:border-neutral-800",
description: "group-[.toast]:text-neutral-500 dark:group-[.toast]:text-neutral-400",
actionButton:
"group-[.toast]:bg-neutral-900 group-[.toast]:text-neutral-50 dark:group-[.toast]:bg-neutral-50 dark:group-[.toast]:text-neutral-900",
cancelButton:
"group-[.toast]:bg-neutral-100 group-[.toast]:text-neutral-500 dark:group-[.toast]:bg-neutral-800 dark:group-[.toast]:text-neutral-400",
},
}}
{...props}
/>
)
}
export { Toaster }

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,7 +0,0 @@
import '../layouts/default'
export default function Home() {
return (
<div>Blog</div>
);
}

View File

@@ -1,7 +0,0 @@
import '../layouts/default'
export default function Home() {
return (
<div>Home</div>
);
}

View File

@@ -1,7 +0,0 @@
import '../layouts/default'
export default function Home() {
return (
<div>Projects</div>
);
}

20
src/sanity/client.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createClient, defineLive } from "next-sanity";
export const client = createClient({
projectId: "zk5oebdb",
dataset: "production",
apiVersion: "2024-10-27",
useCdn: false,
stega: { studioUrl: 'https://vaporvee.sanity.studio' },
});
const token = process.env.SANITY_API_READ_TOKEN
if (!token) {
throw new Error('Missing SANITY_API_READ_TOKEN')
}
export const { sanityFetch, SanityLive } = defineLive({
client,
serverToken: token,
browserToken: token,
});

278
src/sanity/sanity.types.ts Normal file
View File

@@ -0,0 +1,278 @@
/**
* ---------------------------------------------------------------------------------
* This file has been generated by Sanity TypeGen.
* Command: `sanity typegen generate`
*
* Any modifications made directly to this file will be overwritten the next time
* the TypeScript definitions are generated. Please make changes to the Sanity
* schema definitions and/or GROQ queries if you need to update these types.
*
* For more information on how to use Sanity TypeGen, visit the official documentation:
* https://www.sanity.io/docs/sanity-typegen
* ---------------------------------------------------------------------------------
*/
// Source: schema.json
export type SanityImagePaletteSwatch = {
_type: 'sanity.imagePaletteSwatch'
background?: string
foreground?: string
population?: number
title?: string
}
export type SanityImagePalette = {
_type: 'sanity.imagePalette'
darkMuted?: SanityImagePaletteSwatch
lightVibrant?: SanityImagePaletteSwatch
darkVibrant?: SanityImagePaletteSwatch
vibrant?: SanityImagePaletteSwatch
dominant?: SanityImagePaletteSwatch
lightMuted?: SanityImagePaletteSwatch
muted?: SanityImagePaletteSwatch
}
export type SanityImageDimensions = {
_type: 'sanity.imageDimensions'
height?: number
width?: number
aspectRatio?: number
}
export type SanityFileAsset = {
_id: string
_type: 'sanity.fileAsset'
_createdAt: string
_updatedAt: string
_rev: string
originalFilename?: string
label?: string
title?: string
description?: string
altText?: string
sha1hash?: string
extension?: string
mimeType?: string
size?: number
assetId?: string
uploadId?: string
path?: string
url?: string
source?: SanityAssetSourceData
}
export type Geopoint = {
_type: 'geopoint'
lat?: number
lng?: number
alt?: number
}
export type BlockContent = Array<
| {
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal' | 'h1' | 'h2' | 'h3' | 'h4' | 'blockquote'
listItem?: 'bullet'
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}
| {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
_key: string
}
>
export type Category = {
_id: string
_type: 'category'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
description?: string
}
export type Post = {
_id: string
_type: 'post'
_createdAt: string
_updatedAt: string
_rev: string
title?: string
slug?: Slug
author?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'author'
}
mainImage?: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
}
categories?: Array<{
_ref: string
_type: 'reference'
_weak?: boolean
_key: string
[internalGroqTypeReferenceTo]?: 'category'
}>
publishedAt?: string
myCodeField?: Code
body?: BlockContent
}
export type Author = {
_id: string
_type: 'author'
_createdAt: string
_updatedAt: string
_rev: string
name?: string
slug?: Slug
image?: {
asset?: {
_ref: string
_type: 'reference'
_weak?: boolean
[internalGroqTypeReferenceTo]?: 'sanity.imageAsset'
}
hotspot?: SanityImageHotspot
crop?: SanityImageCrop
_type: 'image'
}
bio?: Array<{
children?: Array<{
marks?: Array<string>
text?: string
_type: 'span'
_key: string
}>
style?: 'normal'
listItem?: never
markDefs?: Array<{
href?: string
_type: 'link'
_key: string
}>
level?: number
_type: 'block'
_key: string
}>
}
export type SanityImageCrop = {
_type: 'sanity.imageCrop'
top?: number
bottom?: number
left?: number
right?: number
}
export type SanityImageHotspot = {
_type: 'sanity.imageHotspot'
x?: number
y?: number
height?: number
width?: number
}
export type SanityImageAsset = {
_id: string
_type: 'sanity.imageAsset'
_createdAt: string
_updatedAt: string
_rev: string
originalFilename?: string
label?: string
title?: string
description?: string
altText?: string
sha1hash?: string
extension?: string
mimeType?: string
size?: number
assetId?: string
uploadId?: string
path?: string
url?: string
metadata?: SanityImageMetadata
source?: SanityAssetSourceData
}
export type SanityAssetSourceData = {
_type: 'sanity.assetSourceData'
name?: string
id?: string
url?: string
}
export type SanityImageMetadata = {
_type: 'sanity.imageMetadata'
location?: Geopoint
dimensions?: SanityImageDimensions
palette?: SanityImagePalette
lqip?: string
blurHash?: string
hasAlpha?: boolean
isOpaque?: boolean
}
export type Slug = {
_type: 'slug'
current?: string
source?: string
}
export type Code = {
_type: 'code'
language?: string
filename?: string
code?: string
highlightedLines?: Array<number>
}
export type AllSanitySchemaTypes =
| SanityImagePaletteSwatch
| SanityImagePalette
| SanityImageDimensions
| SanityFileAsset
| Geopoint
| BlockContent
| Category
| Post
| Author
| SanityImageCrop
| SanityImageHotspot
| SanityImageAsset
| SanityAssetSourceData
| SanityImageMetadata
| Slug
| Code
export declare const internalGroqTypeReferenceTo: unique symbol

View File

@@ -4,6 +4,7 @@
:root { :root {
--background: #ffffff; --background: #ffffff;
--foreground: #171717; --foreground: #171717;
--radius: 0.75rem;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {

View File

@@ -1,6 +1,7 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
const config: Config = { const config: Config = {
darkMode: ["class"],
content: [ content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}", "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}", "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
@@ -9,11 +10,16 @@ const config: Config = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
background: "var(--background)", background: 'var(--background)',
foreground: "var(--foreground)", foreground: 'var(--foreground)'
}, },
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
}, },
}, plugins: [require("tailwindcss-animate")],
plugins: [],
}; };
export default config; export default config;