feat: SEO improvements, OG image endpoint, favicon, and metadata (#104)
- Add dynamic OG image generation endpoint (/og) adapted from monorepo - Replace favicon with monorepo version, remove unused icon.svg - Add seo.ts config, robots.ts, sitemap.ts for better search indexing - Add per-page metadata (editor, viewer, settings, profile, community) - Remove stale package-lock.json that caused ENOWORKSPACES errors with Next.js - Add OG fonts (Geist Regular, Plus Jakarta Sans SemiBold) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
867681e1da
commit
9a0f83a1ec
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Editor Workspace',
|
||||
description: 'Edit your Pascal projects in a full 3D workspace.',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
}
|
||||
|
||||
export default function EditorProjectLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return children
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -4,6 +4,7 @@ import { Analytics } from '@vercel/analytics/react'
|
||||
import { SpeedInsights } from '@vercel/speed-insights/next'
|
||||
import { VercelToolbar } from '@vercel/toolbar/next'
|
||||
import { UsernameGate } from '@/features/community/components/username-gate'
|
||||
import { siteConfig } from './seo'
|
||||
import './globals.css'
|
||||
|
||||
const geistSans = localFont({
|
||||
@@ -16,8 +17,48 @@ const geistMono = localFont({
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pascal Editor',
|
||||
description: 'Open-source 3D building editor',
|
||||
metadataBase: new URL(siteConfig.url),
|
||||
title: {
|
||||
default: siteConfig.name,
|
||||
template: '%s | Pascal Editor',
|
||||
},
|
||||
description: siteConfig.description,
|
||||
applicationName: siteConfig.name,
|
||||
keywords: [...siteConfig.keywords],
|
||||
authors: [{ name: 'Pascal', url: 'https://pascal.app' }],
|
||||
creator: 'Pascal',
|
||||
publisher: 'Pascal',
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
},
|
||||
icons: [{ rel: 'icon', url: '/favicon.ico' }],
|
||||
openGraph: {
|
||||
title: siteConfig.name,
|
||||
description: siteConfig.description,
|
||||
url: siteConfig.url,
|
||||
siteName: siteConfig.name,
|
||||
images: [{ url: siteConfig.ogImage, alt: 'Pascal Editor' }],
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: siteConfig.name,
|
||||
description: siteConfig.description,
|
||||
creator: siteConfig.twitterHandle,
|
||||
images: [siteConfig.ogImage],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
'max-video-preview': -1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { ImageResponse } from 'next/og'
|
||||
import * as z from 'zod'
|
||||
import { siteConfig } from '@/app/seo'
|
||||
|
||||
const ogImageSchema = z.object({
|
||||
title: z.string().default(''),
|
||||
description: z.string().default(''),
|
||||
theme: z.enum(['light', 'dark']).default('dark'),
|
||||
})
|
||||
|
||||
let sansFont: ArrayBuffer | null = null
|
||||
let jakartaFont: ArrayBuffer | null = null
|
||||
|
||||
async function loadFonts() {
|
||||
if (!(sansFont && jakartaFont)) {
|
||||
const fontDir = join(process.cwd(), 'public', 'fonts')
|
||||
const [sans, jakarta] = await Promise.all([
|
||||
readFile(join(fontDir, 'geist-regular.ttf')),
|
||||
readFile(join(fontDir, 'PlusJakartaSans-SemiBold.ttf')),
|
||||
])
|
||||
sansFont = sans.buffer.slice(sans.byteOffset, sans.byteOffset + sans.byteLength)
|
||||
jakartaFont = jakarta.buffer.slice(jakarta.byteOffset, jakarta.byteOffset + jakarta.byteLength)
|
||||
}
|
||||
return { sans: sansFont, jakarta: jakartaFont }
|
||||
}
|
||||
|
||||
function PascalLogo({ color = '#fff', size = 100 }: { color?: string; size?: number }) {
|
||||
return (
|
||||
<svg fill="none" height={size} style={{ display: 'flex' }} viewBox="0 0 100 100" width={size}>
|
||||
<rect fill={color} height="40" width="20" y="60" />
|
||||
<rect fill={color} height="40" width="20" x="40" y="30" />
|
||||
<rect fill={color} height="40" width="20" x="80" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const values = ogImageSchema.parse(Object.fromEntries(url.searchParams))
|
||||
const heading =
|
||||
values.title.length > 140 ? `${values.title.substring(0, 140)}...` : values.title
|
||||
|
||||
const { theme } = values
|
||||
const paint = theme === 'dark' ? '#fff' : '#000'
|
||||
const logoColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'
|
||||
|
||||
const fontSize = heading.length > 100 ? '70px' : '100px'
|
||||
|
||||
const showLargeLogo = !(values.title || values.description)
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
color: paint,
|
||||
background:
|
||||
theme === 'dark'
|
||||
? 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 50%, #0f0f0f 100%)'
|
||||
: 'white',
|
||||
}}
|
||||
tw="flex relative flex-col w-full h-full items-start bg-cover"
|
||||
>
|
||||
{showLargeLogo ? (
|
||||
<div tw="flex flex-col flex-1 py-10 px-12 h-full justify-center">
|
||||
<div style={{ display: 'flex', marginBottom: 24 }}>
|
||||
<PascalLogo color={logoColor} size={80} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'jakarta-semibold',
|
||||
fontWeight: 'bolder',
|
||||
fontSize: '72px',
|
||||
letterSpacing: '-0.02em',
|
||||
color: paint,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
Pascal Editor
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'sans-regular',
|
||||
fontWeight: 'normal',
|
||||
fontSize: '28px',
|
||||
letterSpacing: '0.05em',
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
marginTop: 16,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{siteConfig.description}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div tw="flex flex-col flex-1 py-10 px-12 h-full">
|
||||
<div style={{ display: 'flex', marginBottom: 24 }}>
|
||||
<PascalLogo color={logoColor} size={40} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'jakarta-semibold',
|
||||
fontWeight: 'bolder',
|
||||
marginLeft: '-3px',
|
||||
fontSize,
|
||||
letterSpacing: '.05rem',
|
||||
}}
|
||||
tw="flex leading-[1.1] text-[80px] tracking-tighter font-sans mb-4"
|
||||
>
|
||||
{heading}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'sans-regular',
|
||||
fontWeight: 'normal',
|
||||
letterSpacing: '.1rem',
|
||||
}}
|
||||
tw="flex flex-1 text-[50px] tracking-tight font-sans"
|
||||
>
|
||||
{values.description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!showLargeLogo && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: 'sans-regular',
|
||||
fontWeight: 'normal',
|
||||
letterSpacing: '.1rem',
|
||||
}}
|
||||
tw="w-full pt-24 p-10 text-[30px] tracking-tight font-sans text-right w-full"
|
||||
>
|
||||
editor.pascal.app
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: await loadFonts().then(({ sans, jakarta }) => [
|
||||
{
|
||||
name: 'sans-regular',
|
||||
data: sans,
|
||||
style: 'normal' as const,
|
||||
weight: 400 as const,
|
||||
},
|
||||
{
|
||||
name: 'jakarta-semibold',
|
||||
data: jakarta,
|
||||
style: 'normal' as const,
|
||||
weight: 600 as const,
|
||||
},
|
||||
]),
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
console.log('error', error)
|
||||
return new Response('Failed to generate image', {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { Metadata } from 'next'
|
||||
import CommunityHub from '@/features/community/components/community-hub'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Community Projects',
|
||||
description:
|
||||
'Create and share 3D home projects with Pascal Editor, the open-source building editor.',
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return <CommunityHub />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { siteConfig } from './seo'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: ['/', '/viewer/', '/u/'],
|
||||
disallow: ['/api/', '/editor/', '/settings', '/_next/'],
|
||||
},
|
||||
],
|
||||
sitemap: `${siteConfig.url}/sitemap.xml`,
|
||||
host: siteConfig.url,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { BASE_URL } from '@/lib/utils'
|
||||
|
||||
export const siteConfig = {
|
||||
name: 'Pascal Editor',
|
||||
description:
|
||||
'Pascal Editor is an open-source 3D building editor for designing, editing, and sharing home projects.',
|
||||
url: BASE_URL,
|
||||
website: 'editor.pascal.app',
|
||||
ogImage: '/og',
|
||||
keywords: [
|
||||
'Pascal Editor',
|
||||
'open-source 3D editor',
|
||||
'3D building editor',
|
||||
'home design software',
|
||||
'architecture editor',
|
||||
'collaborative design',
|
||||
],
|
||||
twitterHandle: '@pascal_app',
|
||||
links: {
|
||||
github: 'https://github.com/pascalorg/editor',
|
||||
},
|
||||
} as const
|
||||
@@ -1,10 +1,20 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSession } from '@/features/community/lib/auth/server'
|
||||
import { getUserProfile, getConnectedAccounts } from '@/features/community/lib/auth/actions'
|
||||
import { SettingsPage } from '@/features/community/components/settings-page'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Account Settings',
|
||||
description: 'Manage your Pascal Editor account profile and connected providers.',
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
}
|
||||
|
||||
export default async function Settings() {
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { MetadataRoute } from 'next'
|
||||
import { siteConfig } from './seo'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const currentDate = new Date()
|
||||
const baseUrl = siteConfig.url
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: 'daily',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/viewer/demo_1`,
|
||||
lastModified: currentDate,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.7,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,10 +1,27 @@
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getPublicProfile } from '@/features/community/lib/auth/actions'
|
||||
import { getPublicProjectsByUserId } from '@/features/community/lib/projects/actions'
|
||||
import { PublicProfilePage } from '@/features/community/components/public-profile-page'
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ username: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { username } = await params
|
||||
|
||||
return {
|
||||
title: `${username}'s Projects`,
|
||||
description: `Public projects shared by @${username} on Pascal Editor.`,
|
||||
alternates: {
|
||||
canonical: `/u/${encodeURIComponent(username)}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProfilePage({
|
||||
params,
|
||||
}: {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Project Viewer',
|
||||
description: 'View and share 3D projects built with Pascal Editor.',
|
||||
}
|
||||
|
||||
export default function ViewerLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return children
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export function IconRail({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Pascal Logo - Link to Hub */}
|
||||
{/* Pascal logo - link to the home page */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
@@ -52,7 +52,7 @@ export function IconRail({
|
||||
/>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Back to Hub</TooltipContent>
|
||||
<TooltipContent side="right">Back to Pascal Editor</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Divider */}
|
||||
|
||||
@@ -118,18 +118,19 @@ export default function CommunityHub() {
|
||||
height={64}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold">Hub</h1>
|
||||
<h1 className="text-2xl font-bold">Pascal Editor</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="https://github.com/pascalorg/editor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground"
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground transition-colors hover:border-foreground/20 hover:text-foreground"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline text-sm font-medium">Open Source</span>
|
||||
</a>
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
|
||||
@@ -40,7 +40,7 @@ export function PublicProfilePage({ profile, projects }: PublicProfilePageProps)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header — same layout as the Hub */}
|
||||
{/* Header — same layout as the community home */}
|
||||
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -52,17 +52,18 @@ export function PublicProfilePage({ profile, projects }: PublicProfilePageProps)
|
||||
height={64}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<span className="text-2xl font-bold">Hub</span>
|
||||
<span className="text-2xl font-bold">Pascal Editor</span>
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/pascalorg/editor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground"
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground transition-colors hover:border-foreground/20 hover:text-foreground"
|
||||
>
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline text-sm font-medium">Open Source</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user