diff --git a/.gitignore b/.gitignore index 7a45d903..789820ff 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # Dependencies node_modules +package-lock.json .pnp .pnp.js @@ -42,3 +43,4 @@ yarn-error.log* .DS_Store *.pem /.playwright-mcp +og-test diff --git a/apps/editor/app/editor/[projectId]/layout.tsx b/apps/editor/app/editor/[projectId]/layout.tsx new file mode 100644 index 00000000..82f03def --- /dev/null +++ b/apps/editor/app/editor/[projectId]/layout.tsx @@ -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 +} diff --git a/apps/editor/app/favicon.ico b/apps/editor/app/favicon.ico index 718d6fea..e9f36729 100644 Binary files a/apps/editor/app/favicon.ico and b/apps/editor/app/favicon.ico differ diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index ddb23ce8..b378f05b 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -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({ diff --git a/apps/editor/app/og/route.tsx b/apps/editor/app/og/route.tsx new file mode 100644 index 00000000..a7baf229 --- /dev/null +++ b/apps/editor/app/og/route.tsx @@ -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 ( + + + + + + ) +} + +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( +
+ {showLargeLogo ? ( +
+
+ +
+
+ Pascal Editor +
+
+ {siteConfig.description} +
+
+ ) : ( +
+
+ +
+
+ {heading} +
+
+ {values.description} +
+
+ )} + {!showLargeLogo && ( +
+ editor.pascal.app +
+ )} +
, + { + 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, + }) + } +} diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index a7013249..303567bf 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -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 } diff --git a/apps/editor/app/robots.ts b/apps/editor/app/robots.ts new file mode 100644 index 00000000..d1f931f3 --- /dev/null +++ b/apps/editor/app/robots.ts @@ -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, + } +} diff --git a/apps/editor/app/seo.ts b/apps/editor/app/seo.ts new file mode 100644 index 00000000..a87f5215 --- /dev/null +++ b/apps/editor/app/seo.ts @@ -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 diff --git a/apps/editor/app/settings/page.tsx b/apps/editor/app/settings/page.tsx index f588597b..fefd6f20 100644 --- a/apps/editor/app/settings/page.tsx +++ b/apps/editor/app/settings/page.tsx @@ -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) { diff --git a/apps/editor/app/sitemap.ts b/apps/editor/app/sitemap.ts new file mode 100644 index 00000000..4328abf2 --- /dev/null +++ b/apps/editor/app/sitemap.ts @@ -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, + }, + ] +} diff --git a/apps/editor/app/u/[username]/page.tsx b/apps/editor/app/u/[username]/page.tsx index 135e6f42..7b0e7e12 100644 --- a/apps/editor/app/u/[username]/page.tsx +++ b/apps/editor/app/u/[username]/page.tsx @@ -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 { + 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, }: { diff --git a/apps/editor/app/viewer/[id]/layout.tsx b/apps/editor/app/viewer/[id]/layout.tsx new file mode 100644 index 00000000..9a53f302 --- /dev/null +++ b/apps/editor/app/viewer/[id]/layout.tsx @@ -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 +} diff --git a/apps/editor/components/ui/sidebar/icon-rail.tsx b/apps/editor/components/ui/sidebar/icon-rail.tsx index 7f2c2c33..a29d1ff7 100644 --- a/apps/editor/components/ui/sidebar/icon-rail.tsx +++ b/apps/editor/components/ui/sidebar/icon-rail.tsx @@ -36,7 +36,7 @@ export function IconRail({ className, )} > - {/* Pascal Logo - Link to Hub */} + {/* Pascal logo - link to the home page */} - Back to Hub + Back to Pascal Editor {/* Divider */} diff --git a/apps/editor/features/community/components/community-hub.tsx b/apps/editor/features/community/components/community-hub.tsx index c7aa5cf8..839fdc9b 100644 --- a/apps/editor/features/community/components/community-hub.tsx +++ b/apps/editor/features/community/components/community-hub.tsx @@ -118,18 +118,19 @@ export default function CommunityHub() { height={64} className="h-5 w-5" /> -

Hub

+

Pascal Editor

- + + Open Source {!isAuthenticated ? (