├── app ├── favicon.ico ├── (posts) │ ├── [slug] │ │ ├── loading.tsx │ │ ├── page.tsx │ │ └── opengraph-image.tsx │ └── layout.tsx ├── sitemap.ts ├── loading.tsx ├── not-found.tsx ├── provider.tsx ├── page.tsx └── layout.tsx ├── postcss.config.mjs ├── next.config.ts ├── renovate.json ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── lib ├── utils.ts ├── redis.ts ├── actions.ts ├── env.ts └── notion.ts ├── .env.example ├── next-env.d.ts ├── utils └── react-scan.tsx ├── components ├── post │ ├── footer.tsx │ ├── content.tsx │ ├── header.tsx │ ├── related.tsx │ └── loading.tsx ├── ui │ ├── skeleton.tsx │ ├── sonner.tsx │ └── breadcrumb.tsx ├── home │ ├── extra-section.tsx │ ├── loading.tsx │ └── gallery.tsx └── shared │ └── newsletter-form.tsx ├── styles ├── font.ts └── globals.css ├── .vscode └── settings.json ├── components.json ├── .gitignore ├── README.md ├── tsconfig.json ├── turbo.json ├── LICENSE ├── package.json ├── biome.json └── bun.lock /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okisdev/Notion-Photo-React/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ['@tailwindcss/postcss'], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next'; 2 | 3 | const nextConfig: NextConfig = {}; 4 | 5 | export default nextConfig; 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "labels": [ 6 | "dependencies versions-related" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/(posts)/[slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { PhotoLoadingSkeleton } from '@/components/post/loading'; 2 | 3 | export default function PhotoLoading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Notion 2 | NOTION_API_KEY= 3 | NOTION_DATA_SOURCE_ID= 4 | 5 | # Redis 6 | REDIS_URL= 7 | REDIS_TOKEN= 8 | 9 | # Umami 10 | NEXT_PUBLIC_UMAMI_URL= 11 | NEXT_PUBLIC_UMAMI_WEBSITE_ID= 12 | 13 | # Site 14 | NEXT_PUBLIC_SITE_URL= 15 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import "./.next/types/routes.d.ts"; 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 7 | -------------------------------------------------------------------------------- /utils/react-scan.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect } from 'react'; 4 | import { scan } from 'react-scan'; 5 | 6 | export function ReactScan(): null { 7 | useEffect(() => { 8 | scan({ 9 | enabled: true, 10 | }); 11 | }, []); 12 | 13 | return null; 14 | } 15 | -------------------------------------------------------------------------------- /components/post/footer.tsx: -------------------------------------------------------------------------------- 1 | export function PostFooter() { 2 | return ( 3 |
4 |
5 |

6 | Copyright © 2025 Harry Yep. All rights reserved. 7 |

8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /styles/font.ts: -------------------------------------------------------------------------------- 1 | import { Geist, Geist_Mono } from 'next/font/google'; 2 | 3 | const geistSans = Geist({ 4 | variable: '--font-geist-sans', 5 | subsets: ['latin'], 6 | }); 7 | 8 | const geistMono = Geist_Mono({ 9 | variable: '--font-geist-mono', 10 | subsets: ['latin'], 11 | }); 12 | 13 | export const font = { geistSans, geistMono }; 14 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.defaultFormatter": "biomejs.biome", 4 | "editor.formatOnSave": true, 5 | "editor.formatOnPaste": true, 6 | "editor.codeActionsOnSave": { 7 | "source.fixAll.biome": "explicit", 8 | "source.organizeImports.biome": "explicit" 9 | }, 10 | "i18n-ally.localesPaths": [ 11 | "**/locales" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "styles/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /components/post/content.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { getPhotoBySlug } from '@/lib/notion'; 3 | 4 | export async function PhotoContent({ slug }: { slug: string }) { 5 | const photo = await getPhotoBySlug(slug); 6 | 7 | if (!photo) { 8 | return notFound(); 9 | } 10 | 11 | return ( 12 |
13 |
14 | {photo.title} 20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type { MetadataRoute } from 'next'; 2 | import { env } from '@/lib/env'; 3 | import { getAllPhotos } from '@/lib/notion'; 4 | 5 | export default async function sitemap(): Promise { 6 | const photos = await getAllPhotos(); 7 | 8 | return [ 9 | { 10 | url: env.NEXT_PUBLIC_SITE_URL, 11 | lastModified: new Date(), 12 | changeFrequency: 'yearly', 13 | priority: 1, 14 | }, 15 | ...photos.map((photo) => ({ 16 | url: `${env.NEXT_PUBLIC_SITE_URL}/${photo.slug}`, 17 | lastModified: new Date(photo.date), 18 | changeFrequency: 'monthly' as const, 19 | priority: 0.8, 20 | })), 21 | ]; 22 | } 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | # Turborepo 44 | .turbo 45 | .env*.local 46 | -------------------------------------------------------------------------------- /app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { GallerySectionSkeleton } from '@/components/home/loading'; 2 | import { Skeleton } from '@/components/ui/skeleton'; 3 | 4 | export default function HomeLoading() { 5 | return ( 6 |
7 |
8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /app/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | export const generateMetadata = async () => ({ 4 | title: '404', 5 | description: 'Page not found', 6 | }); 7 | 8 | export default async function NotFound() { 9 | return ( 10 |
11 |
12 |

404

13 |

Page not found

14 |
15 | 19 | Go back to home 20 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /components/home/extra-section.tsx: -------------------------------------------------------------------------------- 1 | export function HomeExtraSection() { 2 | return ( 3 |
4 |

More

5 |

6 | Follow Harry Yep on{' '} 7 | 11 | Twitter 12 | {' '} 13 | and{' '} 14 | 18 | GitHub 19 | 20 | . 21 |

22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /components/home/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from '@/components/ui/skeleton'; 2 | 3 | export function GallerySectionSkeleton() { 4 | const skeletonItems = Array.from( 5 | { length: 6 }, 6 | (_, i) => `skeleton-${i + 1}` 7 | ); 8 | 9 | return ( 10 |
11 | {skeletonItems.map((id) => ( 12 |
13 |
14 | 15 |
16 |
17 | 18 | 19 |
20 |
21 | ))} 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion Photo React 2 | 3 | > A Notion Photo powered by Next.js 4 | 5 | > See [Notion Blog React](https://github.com/okisdev/Notion-Blog-React) for a similar project that uses Notion as a database for blog posts. 6 | 7 | ## Demo 8 | 9 | - [nbr.okis.dev](https://nbr.okis.dev) 10 | 11 | ## How to deploy 12 | 13 | Please visit Notion Photo React [Documentation](https://docs.okis.dev/docs/notion-photo-react) for more details. 14 | 15 | ## Problem(s) with deployment 16 | 17 | - email: [hi@okis.dev](mailto:hi@okis.dev) 18 | 19 | ## Alternatives 20 | 21 | > Turn Notion to Blog/Page 22 | 23 | - [React-Notion](https://github.com/splitbee/react-notion) 24 | - [React-Notion-X](https://github.com/NotionX/react-notion-x) 25 | - [Super.so](https://super.so/) 26 | - [Fruition](https://fruitionsite.com/) 27 | 28 | ## Credits 29 | 30 | Copyright (c) 2025 Harry Yep -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "react-jsx", 19 | "incremental": true, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "./*" 28 | ] 29 | } 30 | }, 31 | "include": [ 32 | "next-env.d.ts", 33 | "**/*.ts", 34 | "**/*.tsx", 35 | ".next/types/**/*.ts", 36 | ".next/dev/types/**/*.ts" 37 | ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Analytics } from '@vercel/analytics/react'; 4 | import { SpeedInsights } from '@vercel/speed-insights/next'; 5 | import { AppProgressBar as ProgressBar } from 'next-nprogress-bar'; 6 | import { ThemeProvider } from 'next-themes'; 7 | import { Toaster } from '@/components/ui/sonner'; 8 | 9 | export default function BodyProvider({ 10 | children, 11 | }: Readonly<{ 12 | children: React.ReactNode; 13 | }>) { 14 | return ( 15 | 21 | {children} 22 | 23 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { HomeExtraSection } from '@/components/home/extra-section'; 3 | import { GallerySection } from '@/components/home/gallery'; 4 | import { GallerySectionSkeleton } from '@/components/home/loading'; 5 | import NewsletterForm from '@/components/shared/newsletter-form'; 6 | 7 | export default function Home() { 8 | return ( 9 |
10 |
11 |

12 | Notion Photo React 13 |

14 |

15 | A Notion Photo Gallery powered by Next.js 16 |

17 |
18 | 19 | }> 20 | 21 | 22 | 23 |
24 | 25 |
26 | 27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /lib/redis.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from '@upstash/redis'; 2 | import { env } from '@/lib/env'; 3 | 4 | const redis = new Redis({ 5 | url: env.REDIS_URL, 6 | token: env.REDIS_TOKEN, 7 | }); 8 | 9 | const NEWSLETTER_SUBSCRIBERS_KEY = 'newsletter:subscribers'; 10 | 11 | export async function addNewsletterSubscriber(email: string): Promise { 12 | try { 13 | const exists = await redis.sismember(NEWSLETTER_SUBSCRIBERS_KEY, email); 14 | 15 | if (exists) { 16 | return false; 17 | } 18 | 19 | await redis.sadd(NEWSLETTER_SUBSCRIBERS_KEY, email); 20 | return true; 21 | } catch (error) { 22 | console.error('Error adding newsletter subscriber:', error); 23 | throw error; 24 | } 25 | } 26 | 27 | export async function isNewsletterSubscriber(email: string): Promise { 28 | try { 29 | const result = await redis.sismember(NEWSLETTER_SUBSCRIBERS_KEY, email); 30 | return result === 1; 31 | } catch (error) { 32 | console.error('Error checking newsletter subscriber:', error); 33 | throw error; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /lib/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server'; 2 | 3 | import { addNewsletterSubscriber } from '@/lib/redis'; 4 | 5 | /** 6 | * Server action to add a new newsletter subscriber 7 | * @param email The email address to add 8 | * @returns true if added successfully, false if already exists 9 | */ 10 | export async function subscribeToNewsletter( 11 | email: string 12 | ): Promise<{ success: boolean; message: string }> { 13 | try { 14 | if (!email?.includes('@')) { 15 | return { success: false, message: 'Please enter a valid email address' }; 16 | } 17 | 18 | const isNewSubscriber = await addNewsletterSubscriber(email); 19 | 20 | if (isNewSubscriber) { 21 | return { 22 | success: true, 23 | message: 'Successfully subscribed to newsletter', 24 | }; 25 | } 26 | 27 | return { success: true, message: 'Email already subscribed' }; 28 | } catch (error) { 29 | console.error('Newsletter subscription error:', error); 30 | return { 31 | success: false, 32 | message: 'Failed to subscribe. Please try again.', 33 | }; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "ui": "tui", 4 | "tasks": { 5 | "build": { 6 | "dependsOn": [ 7 | "^build" 8 | ], 9 | "inputs": [ 10 | "$TURBO_DEFAULT$", 11 | ".env*" 12 | ], 13 | "outputs": [ 14 | ".next/**", 15 | "!.next/cache/**" 16 | ], 17 | "env": [] 18 | }, 19 | "lint": { 20 | "dependsOn": [ 21 | "^lint" 22 | ] 23 | }, 24 | "check-types": { 25 | "dependsOn": [ 26 | "^check-types" 27 | ] 28 | }, 29 | "dev": { 30 | "cache": false, 31 | "persistent": true 32 | }, 33 | "start": { 34 | "cache": false, 35 | "persistent": true 36 | }, 37 | "deps:check": { 38 | "dependsOn": [ 39 | "^deps:check" 40 | ] 41 | }, 42 | "deps:update": { 43 | "dependsOn": [ 44 | "^deps:update" 45 | ] 46 | } 47 | }, 48 | "globalEnv": [ 49 | "NODE_ENV", 50 | "NOTION_API_KEY", 51 | "NOTION_DATABASE_ID", 52 | "REDIS_URL", 53 | "REDIS_TOKEN" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Harry Yep 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /components/post/header.tsx: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns'; 2 | import { notFound } from 'next/navigation'; 3 | import { getPhotoBySlug } from '@/lib/notion'; 4 | 5 | export async function PhotoHeader({ slug }: { slug: string }) { 6 | const photo = await getPhotoBySlug(slug); 7 | 8 | if (!photo) { 9 | return notFound(); 10 | } 11 | 12 | return ( 13 |
14 |

{photo.title}

15 |
16 | 19 | {photo.location && photo.location.length > 0 && ( 20 |
21 | {photo.location.map((location) => ( 22 | 26 | {location} 27 | 28 | ))} 29 |
30 | )} 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | CircleCheckIcon, 5 | InfoIcon, 6 | Loader2Icon, 7 | OctagonXIcon, 8 | TriangleAlertIcon, 9 | } from "lucide-react" 10 | import { useTheme } from "next-themes" 11 | import { Toaster as Sonner, type ToasterProps } from "sonner" 12 | 13 | const Toaster = ({ ...props }: ToasterProps) => { 14 | const { theme = "system" } = useTheme() 15 | 16 | return ( 17 | , 22 | info: , 23 | warning: , 24 | error: , 25 | loading: , 26 | }} 27 | style={ 28 | { 29 | "--normal-bg": "var(--popover)", 30 | "--normal-text": "var(--popover-foreground)", 31 | "--normal-border": "var(--border)", 32 | "--border-radius": "var(--radius)", 33 | } as React.CSSProperties 34 | } 35 | {...props} 36 | /> 37 | ) 38 | } 39 | 40 | export { Toaster } 41 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/env.ts: -------------------------------------------------------------------------------- 1 | import { createEnv } from '@t3-oss/env-nextjs'; 2 | import { z } from 'zod'; 3 | 4 | export const env = createEnv({ 5 | server: { 6 | NODE_ENV: z 7 | .enum(['development', 'production', 'test']) 8 | .default('development'), 9 | NOTION_API_KEY: z.string().min(1, 'NOTION_API_KEY is required'), 10 | NOTION_DATA_SOURCE_ID: z 11 | .string() 12 | .min(1, 'NOTION_DATA_SOURCE_ID is required'), 13 | REDIS_URL: z.string().min(1, 'REDIS_URL is required'), 14 | REDIS_TOKEN: z.string().min(1, 'REDIS_TOKEN is required'), 15 | }, 16 | client: { 17 | NEXT_PUBLIC_SITE_URL: z.string().min(1, 'NEXT_PUBLIC_SITE_URL is required'), 18 | NEXT_PUBLIC_UMAMI_URL: z 19 | .string() 20 | .min(1, 'NEXT_PUBLIC_UMAMI_URL is required') 21 | .optional(), 22 | NEXT_PUBLIC_UMAMI_WEBSITE_ID: z 23 | .string() 24 | .min(1, 'NEXT_PUBLIC_UMAMI_WEBSITE_ID is required') 25 | .optional(), 26 | }, 27 | runtimeEnv: { 28 | NODE_ENV: process.env.NODE_ENV, 29 | NOTION_API_KEY: process.env.NOTION_API_KEY, 30 | NOTION_DATA_SOURCE_ID: process.env.NOTION_DATA_SOURCE_ID, 31 | REDIS_URL: process.env.REDIS_URL, 32 | REDIS_TOKEN: process.env.REDIS_TOKEN, 33 | NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, 34 | NEXT_PUBLIC_UMAMI_URL: process.env.NEXT_PUBLIC_UMAMI_URL, 35 | NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID, 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import BodyProvider from '@/app/provider'; 2 | import { cn } from '@/lib/utils'; 3 | import { font } from '@/styles/font'; 4 | import { ReactScan } from '@/utils/react-scan'; 5 | import '@/styles/globals.css'; 6 | import type { Metadata } from 'next'; 7 | import Script from 'next/script'; 8 | import { ViewTransitions } from 'next-view-transitions'; 9 | import { env } from '@/lib/env'; 10 | 11 | export const metadata: Metadata = { 12 | title: { 13 | default: 'Notion Photo React', 14 | template: '%s | Notion Photo React', 15 | }, 16 | description: 'A Notion Photo Gallery powered by Next.js', 17 | }; 18 | 19 | export default async function RootLayout({ 20 | children, 21 | }: { 22 | children: React.ReactNode; 23 | }) { 24 | return ( 25 | 26 | 27 | 28 | 33 | 38 | {env.NEXT_PUBLIC_UMAMI_URL && ( 39 |