feat: add Google OAuth, user profiles, community hub improvements

- Add Google OAuth sign-in alongside magic link authentication
- Username onboarding: require username selection after first sign-in
- Public profile pages at /u/[username] with social links (GitHub, X)
- Settings page for managing username and social links
- Viewer header: unified floating card with project name, @username, breadcrumb
- Guest CTA on viewer page prompting sign-in
- Show owner avatar + username on community project cards
- Redesigned project cards (larger preview, avatar + stats row below)
- Consistent navbar across hub and profile pages with GitHub link
- Footer with links to GitHub repo and npm packages
- Borderless design with natural shadows throughout
- Apple corner smoothing (squircle) progressive enhancement
- cursor:pointer globally on buttons and links
- DB migrations: username, github_url, x_url columns on auth_users
- Root-level db:generate/migrate/push/studio scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-02-19 01:04:37 -05:00
co-authored by Claude Opus 4.6
parent 0fd8656090
commit 62dde6ef5a
30 changed files with 4398 additions and 213 deletions
+15
View File
@@ -119,6 +119,21 @@
body {
@apply bg-background text-foreground;
}
button,
[role="button"],
a {
cursor: pointer;
}
}
/* Apple-style smooth corners (squircle) — progressive enhancement */
.rounded-smooth {
border-radius: var(--radius-lg);
corner-shape: squircle;
}
.rounded-smooth-xl {
border-radius: var(--radius-xl);
corner-shape: squircle;
}
.no-scrollbar::-webkit-scrollbar {
+4 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from 'next'
import localFont from 'next/font/local'
import { UsernameGate } from '@/features/community/components/username-gate'
import './globals.css'
const geistSans = localFont({
@@ -23,7 +24,9 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>{children}</body>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<UsernameGate>{children}</UsernameGate>
</body>
</html>
)
}
+24
View File
@@ -0,0 +1,24 @@
export const dynamic = 'force-dynamic'
import { redirect } from 'next/navigation'
import { getSession } from '@/features/community/lib/auth/server'
import { getUserProfile } from '@/features/community/lib/auth/actions'
import { SettingsPage } from '@/features/community/components/settings-page'
export default async function Settings() {
const session = await getSession()
if (!session?.user) {
redirect('/')
}
const profile = await getUserProfile()
return (
<SettingsPage
user={session.user}
currentUsername={profile?.username ?? null}
currentGithubUrl={profile?.githubUrl ?? null}
currentXUrl={profile?.xUrl ?? null}
/>
)
}
+28
View File
@@ -0,0 +1,28 @@
export const dynamic = 'force-dynamic'
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 default async function ProfilePage({
params,
}: {
params: Promise<{ username: string }>
}) {
const { username } = await params
const profileResult = await getPublicProfile(username)
if (!profileResult.success || !profileResult.data) {
notFound()
}
const projectsResult = await getPublicProjectsByUserId(profileResult.data.id)
return (
<PublicProfilePage
profile={profileResult.data}
projects={projectsResult.data ?? []}
/>
)
}
+9 -4
View File
@@ -8,7 +8,9 @@ import { ViewerCameraControls } from './viewer-camera-controls'
import { ViewerOverlay } from './viewer-overlay'
import { ViewerZoneSystem } from './viewer-zone-system'
import { ThumbnailGenerator } from './thumbnail-generator'
import { ViewerGuestCTA } from './viewer-guest-cta'
import { getProjectModelPublic, incrementProjectViews } from '@/features/community/lib/projects/actions'
import type { ProjectOwner } from '@/features/community/lib/projects/types'
export default function ViewerPage() {
const params = useParams()
@@ -16,6 +18,8 @@ export default function ViewerPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [projectId, setProjectId] = useState<string | null>(null)
const [projectName, setProjectName] = useState<string | null>(null)
const [owner, setOwner] = useState<ProjectOwner | null>(null)
const setScene = useScene((state) => state.setScene)
useEffect(() => {
@@ -32,6 +36,7 @@ export default function ViewerPage() {
setScene(data.nodes, data.rootNodeIds)
initSpatialGridSync()
}
setProjectName('Demo')
} else {
// Load from database (public project)
const result = await getProjectModelPublic(id)
@@ -39,6 +44,8 @@ export default function ViewerPage() {
if (result.success && result.data) {
const { project, model } = result.data
setProjectId(project.id)
setProjectName(project.name)
setOwner((project as any).owner ?? null)
if (model?.scene_graph) {
const { nodes, rootNodeIds } = model.scene_graph
@@ -81,13 +88,11 @@ export default function ViewerPage() {
return (
<div className="relative h-screen w-full">
<ViewerOverlay />
<ViewerOverlay projectName={projectName} owner={owner} />
<ViewerGuestCTA />
<Viewer>
{/* Custom Camera Controls */}
<ViewerCameraControls />
{/* Custom Zone System */}
<ViewerZoneSystem />
{/* Thumbnail Generator */}
<ThumbnailGenerator projectId={projectId || undefined} />
</Viewer>
</div>
@@ -0,0 +1,29 @@
'use client'
import { useState } from 'react'
import { useAuth } from '@/features/community/lib/auth/hooks'
import { SignInDialog } from '@/features/community/components/sign-in-dialog'
export function ViewerGuestCTA() {
const { isAuthenticated, isLoading } = useAuth()
const [showSignIn, setShowSignIn] = useState(false)
if (isLoading || isAuthenticated) return null
return (
<>
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20">
<div className="bg-white/90 backdrop-blur-sm rounded-xl rounded-smooth-xl px-6 py-3 shadow-[0_4px_16px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.03)] flex items-center gap-4">
<p className="text-sm text-neutral-700">Want to create your own 3D project?</p>
<button
onClick={() => setShowSignIn(true)}
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors whitespace-nowrap"
>
Get Started
</button>
</div>
</div>
<SignInDialog open={showSignIn} onOpenChange={setShowSignIn} />
</>
)
}
+83 -46
View File
@@ -2,7 +2,9 @@
import { type AnyNode, type AnyNodeId, type BuildingNode, type LevelNode, type ZoneNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Box, ChevronRight, Diamond, Eye, EyeOff, Image, Layers, Layers2 } from 'lucide-react'
import Link from 'next/link'
import { ArrowLeft, Box, ChevronRight, Diamond, Eye, EyeOff, Image, Layers, Layers2 } from 'lucide-react'
import type { ProjectOwner } from '@/features/community/lib/projects/types'
const getNodeName = (node: AnyNode): string => {
if ('name' in node && node.name) return node.name
@@ -14,7 +16,12 @@ const getNodeName = (node: AnyNode): string => {
return node.type
}
export const ViewerOverlay = () => {
interface ViewerOverlayProps {
projectName?: string | null
owner?: ProjectOwner | null
}
export const ViewerOverlay = ({ projectName, owner }: ViewerOverlayProps) => {
const selection = useViewer((s) => s.selection)
const nodes = useScene((s) => s.nodes)
const showScans = useViewer((s) => s.showScans)
@@ -59,60 +66,90 @@ export const ViewerOverlay = () => {
return (
<>
{/* Unified top-left card */}
<div className="absolute top-4 left-4 z-10 flex flex-col gap-3">
{/* Breadcrumb */}
<div className="flex items-center gap-1 text-sm">
<button
onClick={() => handleBreadcrumbClick('root')}
className="text-neutral-500 hover:text-neutral-800 transition-colors"
>
Site
</button>
<div className="bg-white/80 backdrop-blur-sm rounded-lg rounded-smooth shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] overflow-hidden">
{/* Project info + back */}
<div className="flex items-center gap-3 px-3 py-2">
<Link
href="/"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 transition-colors"
>
<ArrowLeft className="h-4 w-4 text-neutral-500" />
</Link>
<div className="min-w-0">
<div className="text-sm font-medium text-neutral-800 truncate">
{projectName || 'Untitled'}
</div>
{owner?.username && (
<Link
href={`/u/${owner.username}`}
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
@{owner.username}
</Link>
)}
</div>
</div>
{/* Breadcrumb — only shown when navigated into a building */}
{building && (
<>
<ChevronRight className="w-4 h-4 text-neutral-400" />
<div className="border-t border-neutral-100 px-3 py-1.5">
<div className="flex items-center gap-1 text-xs">
<button
onClick={() => handleBreadcrumbClick('building')}
className={`transition-colors ${level ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
onClick={() => handleBreadcrumbClick('root')}
className="text-neutral-500 hover:text-neutral-800 transition-colors"
>
{building.name || 'Building'}
Site
</button>
</>
)}
{level && (
<>
<ChevronRight className="w-4 h-4 text-neutral-400" />
<button
onClick={() => handleBreadcrumbClick('level')}
className={`transition-colors ${zone ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
>
{level.name || `Level ${level.level}`}
</button>
</>
)}
{building && (
<>
<ChevronRight className="w-3 h-3 text-neutral-400" />
<button
onClick={() => handleBreadcrumbClick('building')}
className={`transition-colors truncate ${level ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
>
{building.name || 'Building'}
</button>
</>
)}
{zone && (
<>
<ChevronRight className="w-4 h-4 text-neutral-400" />
<span className={`transition-colors ${selectedNode ? 'text-neutral-500' : 'text-neutral-800 font-medium'}`}>
{zone.name}
</span>
</>
)}
{level && (
<>
<ChevronRight className="w-3 h-3 text-neutral-400" />
<button
onClick={() => handleBreadcrumbClick('level')}
className={`transition-colors truncate ${zone ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
>
{level.name || `Level ${level.level}`}
</button>
</>
)}
{selectedNode && zone && (
<>
<ChevronRight className="w-4 h-4 text-neutral-400" />
<span className="text-neutral-800 font-medium">{getNodeName(selectedNode)}</span>
</>
{zone && (
<>
<ChevronRight className="w-3 h-3 text-neutral-400" />
<span className={`transition-colors truncate ${selectedNode ? 'text-neutral-500' : 'text-neutral-800 font-medium'}`}>
{zone.name}
</span>
</>
)}
{selectedNode && zone && (
<>
<ChevronRight className="w-3 h-3 text-neutral-400" />
<span className="text-neutral-800 font-medium truncate">{getNodeName(selectedNode)}</span>
</>
)}
</div>
</div>
)}
</div>
{/* Level List (only when building is selected) */}
{building && levels.length > 0 && (
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200 w-40">
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] w-40">
<span className="text-xs text-neutral-500 px-2 pb-1">Levels</span>
{levels.map((lvl) => (
<button
@@ -134,7 +171,7 @@ export const ViewerOverlay = () => {
{/* Controls Panel - Top Right */}
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2">
{/* Visibility Controls */}
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200">
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
<span className="text-xs text-neutral-500 px-2 pb-1">Visibility</span>
<button
onClick={() => useViewer.getState().setShowScans(!showScans)}
@@ -157,7 +194,7 @@ export const ViewerOverlay = () => {
</div>
{/* Camera Mode */}
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200">
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
<span className="text-xs text-neutral-500 px-2 pb-1">Camera</span>
<button
onClick={() => useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
@@ -173,7 +210,7 @@ export const ViewerOverlay = () => {
</div>
{/* Level Mode */}
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200">
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
<span className="text-xs text-neutral-500 px-2 pb-1">Level Mode</span>
<button
onClick={() => useViewer.getState().setLevelMode('stacked')}
@@ -205,7 +242,7 @@ export const ViewerOverlay = () => {
</div>
{/* Wall Mode */}
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200">
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
<span className="text-xs text-neutral-500 px-2 pb-1">Wall Mode</span>
<button
onClick={() => useViewer.getState().setWallMode('cutaway')}