diff --git a/next-14/src/app/SanityLive.tsx b/next-14/src/app/SanityLive.tsx new file mode 100644 index 0000000..1948a5e --- /dev/null +++ b/next-14/src/app/SanityLive.tsx @@ -0,0 +1,48 @@ +'use client' + +import type {LiveEventMessage, LiveEventRestart, LiveEventWelcome} from '@sanity/client' +import {CorsOriginError} from '@sanity/client' +import {useRouter} from 'next/navigation' +import {useEffect} from 'react' +import {useEffectEvent} from 'use-effect-event' +import {client} from '../sanity/client' +import {expireTags} from './actions' + +export function SanityLive() { + const router = useRouter() + + const handleLiveEvent = useEffectEvent( + (event: LiveEventMessage | LiveEventRestart | LiveEventWelcome) => { + if (event.type === 'welcome') { + console.info('Sanity is live with automatic revalidation of published content') + } else if (event.type === 'message') { + expireTags(event.tags) + } else if (event.type === 'restart') { + router.refresh() + } + }, + ) + useEffect(() => { + const subscription = client.live.events().subscribe({ + next: (event) => { + if (event.type === 'message' || event.type === 'restart' || event.type === 'welcome') { + handleLiveEvent(event) + } + }, + error: (error: unknown) => { + if (error instanceof CorsOriginError) { + console.warn( + `Sanity Live is unable to connect to the Sanity API as the current origin - ${window.origin} - is not in the list of allowed CORS origins for this Sanity Project.`, + error.addOriginUrl && `Add it here:`, + error.addOriginUrl?.toString(), + ) + } else { + console.error(error) + } + }, + }) + return () => subscription.unsubscribe() + }, [handleLiveEvent]) + + return null +} diff --git a/next-14/src/app/ThemeButton.tsx b/next-14/src/app/ThemeButton.tsx new file mode 100644 index 0000000..012d97b --- /dev/null +++ b/next-14/src/app/ThemeButton.tsx @@ -0,0 +1,24 @@ +'use client' + +import type {SyncTag} from '@sanity/client' +import {useTransition} from 'react' +import {randomColorTheme} from './actions' + +export function ThemeButton({tags}: {tags: SyncTag[]}) { + const [pending, startTransition] = useTransition() + return ( + + ) +} diff --git a/next-14/src/app/TimeSince.tsx b/next-14/src/app/TimeSince.tsx new file mode 100644 index 0000000..e912a12 --- /dev/null +++ b/next-14/src/app/TimeSince.tsx @@ -0,0 +1,45 @@ +'use client' + +import {useLayoutEffect, useState} from 'react' + +export function TimeSince({label, since}: {label: string; since: string}) { + const [from, setFrom] = useState(null) + const [now, setNow] = useState(null) + useLayoutEffect(() => { + setFrom(new Date(since)) + const interval = setInterval(() => setNow(new Date()), 1000) + return () => clearInterval(interval) + }, [since]) + + let timeSince = '…' + if (from && now) { + timeSince = formatTimeSince(from, now) + } + + return ( +
+ {label}: + + fetched {timeSince} + +
+ ) +} + +const rtf = new Intl.RelativeTimeFormat('en', {style: 'short'}) +export function formatTimeSince(from: Date, to: Date): string { + const seconds = Math.floor((from.getTime() - to.getTime()) / 1000) + if (seconds > -60) { + return rtf.format(Math.min(seconds, -1), 'second') + } + const minutes = Math.ceil(seconds / 60) + if (minutes > -60) { + return rtf.format(minutes, 'minute') + } + const hours = Math.ceil(minutes / 60) + if (hours > -24) { + return rtf.format(hours, 'hour') + } + const days = Math.ceil(hours / 24) + return rtf.format(days, 'day') +} diff --git a/next-14/src/app/actions.ts b/next-14/src/app/actions.ts new file mode 100644 index 0000000..1bd4eb7 --- /dev/null +++ b/next-14/src/app/actions.ts @@ -0,0 +1,21 @@ +'use server' + +import type {SyncTag} from '@sanity/client' +import {revalidateTag} from 'next/cache' + +export async function expireTags(tags: SyncTag[]) { + for (const tag of tags) { + revalidateTag(tag) + } + console.log(` expired tags: ${tags.join(', ')}`) +} + +export async function randomColorTheme(tags: SyncTag[]) { + const res = await fetch('https://lcapi-examples-api.sanity.dev/api/random-color-theme', { + method: 'PUT', + }) + for (const tag of tags) { + revalidateTag(tag) + } + return res.json() +} diff --git a/next-14/src/app/globals.css b/next-14/src/app/globals.css index 13d40b8..ed0ea58 100644 --- a/next-14/src/app/globals.css +++ b/next-14/src/app/globals.css @@ -2,26 +2,14 @@ @tailwind components; @tailwind utilities; -:root { - --background: #ffffff; - --foreground: #171717; +@property --theme-text { + syntax: ''; + inherits: true; + initial-value: #000; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - -body { - color: var(--foreground); - background: var(--background); - font-family: Arial, Helvetica, sans-serif; -} - -@layer utilities { - .text-balance { - text-wrap: balance; - } +@property --theme-background { + syntax: ''; + inherits: true; + initial-value: #fff; } diff --git a/next-14/src/app/layout.tsx b/next-14/src/app/layout.tsx index f4445ff..d958368 100644 --- a/next-14/src/app/layout.tsx +++ b/next-14/src/app/layout.tsx @@ -1,31 +1,47 @@ -import type {Metadata} from 'next' -import localFont from 'next/font/local' import './globals.css' +import {sanityFetch} from '@/sanity/fetch' +import {defineQuery} from 'groq' +import {Suspense} from 'react' +import {SanityLive} from './SanityLive' +import {ThemeButton} from './ThemeButton' +import {TimeSince} from './TimeSince' -const geistSans = localFont({ - src: './fonts/GeistVF.woff', - variable: '--font-geist-sans', - weight: '100 900', -}) -const geistMono = localFont({ - src: './fonts/GeistMonoVF.woff', - variable: '--font-geist-mono', - weight: '100 900', -}) +const THEME_QUERY = defineQuery( + `*[_id == "theme"][0]{background,text,"fetchedAt": dateTime(now())}`, +) -export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', -} - -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { + const {data, tags} = await sanityFetch({query: THEME_QUERY}) + return ( - - {children} + + +
+ {data?.fetchedAt && ( + + + + )} + {children} + + + +
+ + + + ) } diff --git a/next-14/src/app/page.tsx b/next-14/src/app/page.tsx index 8bf395f..4bd87d2 100644 --- a/next-14/src/app/page.tsx +++ b/next-14/src/app/page.tsx @@ -1,101 +1,35 @@ -import Image from 'next/image' +import {sanityFetch} from '@/sanity/fetch' +import './globals.css' +import {defineQuery} from 'groq' +import type {Metadata} from 'next' +import {Suspense} from 'react' +import {TimeSince} from './TimeSince' -export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{' '} - - src/app/page.tsx - - . -
  2. -
  3. Save and see your changes instantly.
  4. -
+const DEMO_QUERY = defineQuery( + `*[_type == "demo" && slug.current == $slug][0]{title,"fetchedAt": dateTime(now())}`, +) +const slug = 'next-14' + +export async function generateMetadata(): Promise { + const {data} = await sanityFetch({query: DEMO_QUERY, params: {slug}}) + return { + title: data?.title || 'Next 14', + } +} - -
- +export default async function Home() { + const {data} = await sanityFetch({query: DEMO_QUERY, params: {slug}}) + + return ( +
+

+ {data?.title || 'Next 14'} +

+ {data?.fetchedAt && ( + + + + )}
) } diff --git a/next-14/src/sanity/client.ts b/next-14/src/sanity/client.ts new file mode 100644 index 0000000..a8438ab --- /dev/null +++ b/next-14/src/sanity/client.ts @@ -0,0 +1,8 @@ +import {createClient} from '@sanity/client' + +export const client = createClient({ + projectId: 'hiomol4a', + dataset: 'lcapi', + apiVersion: '2024-09-22', + useCdn: false, +}) diff --git a/next-14/src/sanity/fetch.ts b/next-14/src/sanity/fetch.ts new file mode 100644 index 0000000..5ade27d --- /dev/null +++ b/next-14/src/sanity/fetch.ts @@ -0,0 +1,19 @@ +import {type QueryParams} from '@sanity/client' +import {client} from './client' + +export async function sanityFetch({ + query, + params = {}, +}: { + query: QueryString + params?: QueryParams +}) { + // Uncached query that fetches cache tags (on Next 15 uncached doesn't mean on every browser request, but on every ISR build) + const {syncTags: tags} = await client.fetch(query, params, { + filterResponse: false, + tag: 'fetch-sync-tags', // The request tag makes the fetch unique, avoids deduping with the cached query that has tags + next: {revalidate: 1}, // Ensure we don't opt out of ISR caching + }) + const data = await client.fetch(query, params, {next: {revalidate: false, tags}}) + return {data, tags} +} diff --git a/next-14/tailwind.config.ts b/next-14/tailwind.config.ts index 226e32a..f3b29eb 100644 --- a/next-14/tailwind.config.ts +++ b/next-14/tailwind.config.ts @@ -1,6 +1,6 @@ import type {Config} from 'tailwindcss' -const config: Config = { +export default { content: [ './src/pages/**/*.{js,ts,jsx,tsx,mdx}', './src/components/**/*.{js,ts,jsx,tsx,mdx}', @@ -8,12 +8,21 @@ const config: Config = { ], theme: { extend: { - colors: { - background: 'var(--background)', - foreground: 'var(--foreground)', + backgroundColor: { + 'theme': 'var(--theme-background,#fff)', + 'theme-button': 'var(--theme-text,#fff)', + }, + textColor: { + 'theme': 'var(--theme-text,#000)', + 'theme-button': 'var(--theme-background,#fff)', + }, + ringColor: { + theme: 'var(--theme-text,#000)', + }, + ringOffsetColor: { + theme: 'var(--theme-background,#fff)', }, }, }, plugins: [], -} -export default config +} satisfies Config