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:
co-authored by
Claude Opus 4.6
parent
0fd8656090
commit
62dde6ef5a
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 ?? []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user