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')}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createLocalProject, getLocalProjects } from '../lib/local-storage/proje
|
||||
import { getPublicProjects, getUserProjects } from '../lib/projects/actions'
|
||||
import type { Project } from '../lib/projects/types'
|
||||
import { CreateProjectButton } from './create-project-button'
|
||||
import { HubFooter } from './hub-footer'
|
||||
import { NewProjectDialog } from './new-project-dialog'
|
||||
import { ProfileDropdown } from './profile-dropdown'
|
||||
import { ProjectGrid } from './project-grid'
|
||||
@@ -119,16 +120,28 @@ export default function CommunityHub() {
|
||||
/>
|
||||
<h1 className="text-2xl font-bold">Hub</h1>
|
||||
</div>
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
onClick={() => setIsSignInDialogOpen(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||
<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"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
) : (
|
||||
<ProfileDropdown />
|
||||
)}
|
||||
<svg className="h-5 w-5" 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>
|
||||
</a>
|
||||
{!isAuthenticated ? (
|
||||
<button
|
||||
onClick={() => setIsSignInDialogOpen(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
) : (
|
||||
<ProfileDropdown />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -209,6 +222,8 @@ export default function CommunityHub() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<HubFooter />
|
||||
|
||||
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
|
||||
<NewProjectDialog
|
||||
open={isNewProjectDialogOpen}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function CreateProjectButton({ onCreateProject }: CreateProjectButtonProp
|
||||
return (
|
||||
<button
|
||||
onClick={onCreateProject}
|
||||
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
className="flex items-center gap-2 rounded-full bg-primary px-5 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Create Project</span>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} 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>
|
||||
)
|
||||
}
|
||||
|
||||
function NpmIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 256 256">
|
||||
<rect width="256" height="256" rx="0" fill="#C12127" />
|
||||
<polygon points="48,48 208,48 208,208 176,208 176,80 128,80 128,208 48,208" fill="#FFFFFF" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function HubFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border mt-16">
|
||||
<div className="container mx-auto px-6 py-8">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Editor by{' '}
|
||||
<a
|
||||
href="https://pascal.app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Pascal
|
||||
</a>
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href="https://github.com/pascalorg/editor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||
>
|
||||
<GitHubIcon className="h-4 w-4" />
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href="https://www.npmjs.com/package/@pascal-app/viewer"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||
>
|
||||
<NpmIcon className="h-4 w-4" />
|
||||
Viewer
|
||||
</a>
|
||||
<a
|
||||
href="https://www.npmjs.com/package/@pascal-app/core"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||
>
|
||||
<NpmIcon className="h-4 w-4" />
|
||||
Core
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAuth } from '../lib/auth/hooks'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/primitives/dropdown-menu'
|
||||
|
||||
@@ -18,15 +21,14 @@ function getInitials(name: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* ProfileDropdown - User profile menu with sign out
|
||||
* ProfileDropdown - User profile menu with avatar, settings, and sign out
|
||||
*/
|
||||
export function ProfileDropdown() {
|
||||
const { user, signOut } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut()
|
||||
// TODO: Show sign-in dialog or redirect
|
||||
console.log('Signed out')
|
||||
}
|
||||
|
||||
const initials = user?.name ? getInitials(user.name) : user?.email?.[0]?.toUpperCase() || 'U'
|
||||
@@ -35,20 +37,49 @@ export function ProfileDropdown() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-background/95 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none"
|
||||
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg border border-border bg-background/95 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none"
|
||||
type="button"
|
||||
>
|
||||
{initials}
|
||||
{user?.image ? (
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={user.name || 'Profile'}
|
||||
width={36}
|
||||
height={36}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{user?.name && (
|
||||
<div className="px-2 py-1.5 text-sm">
|
||||
<div className="font-medium">{user.name}</div>
|
||||
{user.email && <div className="text-muted-foreground text-xs">{user.email}</div>}
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="flex items-center gap-3 px-2 py-2">
|
||||
{user?.image ? (
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={user.name || 'Profile'}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-xs">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{user?.name && <div className="truncate font-medium text-sm">{user.name}</div>}
|
||||
{user?.email && (
|
||||
<div className="truncate text-muted-foreground text-xs">{user.email}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{user?.name && <DropdownMenuItem className="h-px bg-border" />}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer" onClick={() => router.push('/settings')}>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="cursor-pointer" variant="destructive" onClick={handleSignOut}>
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Eye, Heart, Settings } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { Project } from '../lib/projects/types'
|
||||
import type { LocalProject } from '../lib/local-storage/project-store'
|
||||
@@ -116,105 +117,144 @@ export function ProjectGrid({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => onProjectClick(project.id)}
|
||||
className="group relative overflow-hidden rounded-lg border border-border bg-card hover:border-primary transition-all text-left cursor-pointer"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-video bg-muted relative">
|
||||
{!isLocalProject(project) && project.thumbnail_url ? (
|
||||
<img
|
||||
src={project.thumbnail_url}
|
||||
alt={project.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
No preview
|
||||
</div>
|
||||
)}
|
||||
{isLocalProject(project) && (
|
||||
<div className="absolute top-2 right-2">
|
||||
{isAuthenticated && onSaveToCloud ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSaveToCloud(project)
|
||||
}}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white text-xs px-2 py-1 rounded transition-colors"
|
||||
title="Save to cloud"
|
||||
>
|
||||
Save to cloud
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-blue-500 text-white text-xs px-2 py-1 rounded">
|
||||
Local
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && !isLocalProject(project) && (
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onViewClick && (
|
||||
<button
|
||||
onClick={(e) => handleViewClick(e, project.id)}
|
||||
className="bg-background/80 hover:bg-background rounded-md p-1.5"
|
||||
aria-label="View"
|
||||
title="View in viewer mode"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleSettingsClick(e, project)}
|
||||
className="bg-background/80 hover:bg-background rounded-md p-1.5"
|
||||
aria-label="Settings"
|
||||
title="Project settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{projects.map((project) => {
|
||||
const owner = !isLocalProject(project) ? project.owner : null
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-4">
|
||||
<h3 className="font-medium text-left line-clamp-2 mb-2">{project.name}</h3>
|
||||
|
||||
{!isLocalProject(project) && (
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>{project.views}</span>
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
onClick={() => onProjectClick(project.id)}
|
||||
className="group text-left cursor-pointer"
|
||||
>
|
||||
{/* Thumbnail card */}
|
||||
<div className="relative aspect-[4/3] rounded-xl rounded-smooth-xl bg-neutral-50 overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.04)] transition-shadow group-hover:shadow-[0_4px_12px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.04)]">
|
||||
{!isLocalProject(project) && project.thumbnail_url ? (
|
||||
<img
|
||||
src={project.thumbnail_url}
|
||||
alt={project.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
No preview
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => handleLikeClick(e, project.id)}
|
||||
className="flex items-center gap-1 hover:text-red-500 transition-colors"
|
||||
disabled={!isAuthenticated}
|
||||
>
|
||||
<Heart
|
||||
className={`w-4 h-4 ${
|
||||
userLikes[project.id]
|
||||
? 'fill-red-500 text-red-500'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
<span>{likeCounts[project.id] ?? project.likes}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{isLocalProject(project) && (
|
||||
<div className="absolute top-3 right-3">
|
||||
{isAuthenticated && onSaveToCloud ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onSaveToCloud(project)
|
||||
}}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white text-xs px-2.5 py-1 rounded-md transition-colors"
|
||||
title="Save to cloud"
|
||||
>
|
||||
Save to cloud
|
||||
</button>
|
||||
) : (
|
||||
<div className="bg-blue-500 text-white text-xs px-2.5 py-1 rounded-md">
|
||||
Local
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canEdit && !isLocalProject(project) && (
|
||||
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onViewClick && (
|
||||
<button
|
||||
onClick={(e) => handleViewClick(e, project.id)}
|
||||
className="bg-background/80 hover:bg-background rounded-md p-1.5"
|
||||
aria-label="View"
|
||||
title="View in viewer mode"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleSettingsClick(e, project)}
|
||||
className="bg-background/80 hover:bg-background rounded-md p-1.5"
|
||||
aria-label="Settings"
|
||||
title="Project settings"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLocalProject(project) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(project.updated_at).toLocaleDateString()}
|
||||
{/* Info row below the card */}
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
{/* Avatar */}
|
||||
{showOwner && owner ? (
|
||||
<Link
|
||||
href={owner.username ? `/u/${owner.username}` : '#'}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0"
|
||||
>
|
||||
{owner.image ? (
|
||||
<img
|
||||
src={owner.image}
|
||||
alt={owner.name}
|
||||
className="w-9 h-9 rounded-full object-cover shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.1)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-neutral-100 flex items-center justify-center text-sm font-medium shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
||||
{owner.name?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{/* Name + stats */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-medium text-sm truncate">{project.name}</h3>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
{showOwner && owner && (
|
||||
<>
|
||||
<Link
|
||||
href={owner.username ? `/u/${owner.username}` : '#'}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="hover:text-foreground transition-colors truncate"
|
||||
>
|
||||
{owner.username || owner.name}
|
||||
</Link>
|
||||
{!isLocalProject(project) && <span className="shrink-0">·</span>}
|
||||
</>
|
||||
)}
|
||||
{!isLocalProject(project) && (
|
||||
<>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>{project.views}</span>
|
||||
</div>
|
||||
<span className="shrink-0">·</span>
|
||||
<button
|
||||
onClick={(e) => handleLikeClick(e, project.id)}
|
||||
className="flex items-center gap-0.5 shrink-0 hover:text-red-500 transition-colors"
|
||||
disabled={!isAuthenticated}
|
||||
>
|
||||
<Heart
|
||||
className={`w-3.5 h-3.5 ${
|
||||
userLikes[project.id]
|
||||
? 'fill-red-500 text-red-500'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
<span>{likeCounts[project.id] ?? project.likes}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isLocalProject(project) && (
|
||||
<span>{new Date(project.updated_at).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Settings Dialog */}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ProjectGrid } from './project-grid'
|
||||
import { HubFooter } from './hub-footer'
|
||||
import type { Project } from '../lib/projects/types'
|
||||
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} 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>
|
||||
)
|
||||
}
|
||||
|
||||
function XIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface PublicProfilePageProps {
|
||||
profile: {
|
||||
id: string
|
||||
name: string
|
||||
image: string | null
|
||||
username: string
|
||||
githubUrl: string | null
|
||||
xUrl: string | null
|
||||
}
|
||||
projects: Project[]
|
||||
}
|
||||
|
||||
export function PublicProfilePage({ profile, projects }: PublicProfilePageProps) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header — same layout as the Hub */}
|
||||
<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">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/pascal-logo-shape.svg"
|
||||
alt="Pascal"
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
<span className="text-2xl font-bold">Hub</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"
|
||||
>
|
||||
<svg className="h-5 w-5" 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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-4xl px-6 py-8 space-y-8">
|
||||
{/* Profile Header */}
|
||||
<div className="flex items-center gap-6">
|
||||
{profile.image ? (
|
||||
<Image
|
||||
src={profile.image}
|
||||
alt={profile.name}
|
||||
width={80}
|
||||
height={80}
|
||||
className="h-20 w-20 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted font-bold text-2xl">
|
||||
{profile.name[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold">{profile.name}</h1>
|
||||
<p className="text-muted-foreground">@{profile.username}</p>
|
||||
{(profile.githubUrl || profile.xUrl) && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
{profile.githubUrl && (
|
||||
<a
|
||||
href={profile.githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<GitHubIcon className="h-5 w-5" />
|
||||
</a>
|
||||
)}
|
||||
{profile.xUrl && (
|
||||
<a
|
||||
href={profile.xUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projects */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold mb-4">Public Projects</h2>
|
||||
{projects.length > 0 ? (
|
||||
<ProjectGrid
|
||||
projects={projects}
|
||||
onProjectClick={(id) => router.push(`/viewer/${id}`)}
|
||||
showOwner={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
No public projects yet
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<HubFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { updateUsername, updateProfile } from '../lib/auth/actions'
|
||||
|
||||
interface SettingsPageProps {
|
||||
user: {
|
||||
id: string
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
image?: string | null
|
||||
}
|
||||
currentUsername: string | null
|
||||
currentGithubUrl: string | null
|
||||
currentXUrl: string | null
|
||||
}
|
||||
|
||||
export function SettingsPage({
|
||||
user,
|
||||
currentUsername,
|
||||
currentGithubUrl,
|
||||
currentXUrl,
|
||||
}: SettingsPageProps) {
|
||||
const [username, setUsername] = useState(currentUsername ?? '')
|
||||
const [githubUrl, setGithubUrl] = useState(currentGithubUrl ?? '')
|
||||
const [xUrl, setXUrl] = useState(currentXUrl ?? '')
|
||||
const [isSavingUsername, setIsSavingUsername] = useState(false)
|
||||
const [isSavingSocial, setIsSavingSocial] = useState(false)
|
||||
const [usernameMessage, setUsernameMessage] = useState<{
|
||||
type: 'success' | 'error'
|
||||
text: string
|
||||
} | null>(null)
|
||||
const [socialMessage, setSocialMessage] = useState<{
|
||||
type: 'success' | 'error'
|
||||
text: string
|
||||
} | null>(null)
|
||||
|
||||
const handleSaveUsername = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setUsernameMessage(null)
|
||||
setIsSavingUsername(true)
|
||||
|
||||
const result = await updateUsername(username)
|
||||
|
||||
if (result.success) {
|
||||
setUsernameMessage({ type: 'success', text: 'Username updated successfully' })
|
||||
} else {
|
||||
setUsernameMessage({ type: 'error', text: result.error ?? 'Failed to update username' })
|
||||
}
|
||||
|
||||
setIsSavingUsername(false)
|
||||
}
|
||||
|
||||
const handleSaveSocial = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSocialMessage(null)
|
||||
setIsSavingSocial(true)
|
||||
|
||||
const result = await updateProfile({
|
||||
githubUrl: githubUrl.trim() || null,
|
||||
xUrl: xUrl.trim() || null,
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
setSocialMessage({ type: 'success', text: 'Social links updated successfully' })
|
||||
} else {
|
||||
setSocialMessage({ type: 'error', text: result.error ?? 'Failed to update social links' })
|
||||
}
|
||||
|
||||
setIsSavingSocial(false)
|
||||
}
|
||||
|
||||
const usernameChanged = username.trim() !== (currentUsername ?? '')
|
||||
const socialChanged =
|
||||
(githubUrl.trim() || '') !== (currentGithubUrl ?? '') ||
|
||||
(xUrl.trim() || '') !== (currentXUrl ?? '')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<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 gap-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="text-sm">Back</span>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto max-w-2xl px-6 py-8 space-y-8">
|
||||
{/* Profile Section */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Profile</h2>
|
||||
<div className="rounded-lg border border-border p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{user.image ? (
|
||||
<Image
|
||||
src={user.image}
|
||||
alt={user.name || 'Profile'}
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-16 w-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted font-semibold text-lg">
|
||||
{user.name?.[0]?.toUpperCase() || user.email?.[0]?.toUpperCase() || 'U'}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{user.name && <div className="font-medium">{user.name}</div>}
|
||||
{user.email && (
|
||||
<div className="text-muted-foreground text-sm">{user.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveUsername} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="username" className="font-medium text-sm">
|
||||
Public Username
|
||||
</label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Your public display name on the community hub.
|
||||
</p>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value)
|
||||
setUsernameMessage(null)
|
||||
}}
|
||||
placeholder="your-username"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSavingUsername}
|
||||
minLength={3}
|
||||
maxLength={30}
|
||||
pattern="[a-zA-Z0-9_-]+"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
3-30 characters. Letters, numbers, hyphens, and underscores only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{usernameMessage && (
|
||||
<div
|
||||
className={`rounded-md border p-3 text-sm ${
|
||||
usernameMessage.type === 'success'
|
||||
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'border-destructive/50 bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{usernameMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSavingUsername || !usernameChanged || !username.trim()}
|
||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isSavingUsername ? 'Saving...' : 'Save Username'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Social Links Section */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Social Links</h2>
|
||||
<div className="rounded-lg border border-border p-6">
|
||||
<form onSubmit={handleSaveSocial} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="github" className="font-medium text-sm">
|
||||
GitHub
|
||||
</label>
|
||||
<input
|
||||
id="github"
|
||||
type="url"
|
||||
value={githubUrl}
|
||||
onChange={(e) => {
|
||||
setGithubUrl(e.target.value)
|
||||
setSocialMessage(null)
|
||||
}}
|
||||
placeholder="https://github.com/yourusername"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSavingSocial}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="x" className="font-medium text-sm">
|
||||
X (Twitter)
|
||||
</label>
|
||||
<input
|
||||
id="x"
|
||||
type="url"
|
||||
value={xUrl}
|
||||
onChange={(e) => {
|
||||
setXUrl(e.target.value)
|
||||
setSocialMessage(null)
|
||||
}}
|
||||
placeholder="https://x.com/yourusername"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSavingSocial}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{socialMessage && (
|
||||
<div
|
||||
className={`rounded-md border p-3 text-sm ${
|
||||
socialMessage.type === 'success'
|
||||
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||
: 'border-destructive/50 bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{socialMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSavingSocial || !socialChanged}
|
||||
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isSavingSocial ? 'Saving...' : 'Save Social Links'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,22 +10,59 @@ interface SignInDialogProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SignInDialog - Magic link authentication dialog
|
||||
* SignInDialog - Authentication dialog with Google OAuth and magic link
|
||||
*/
|
||||
export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleGoogleSignIn = async () => {
|
||||
setError(null)
|
||||
setIsGoogleLoading(true)
|
||||
try {
|
||||
await authClient.signIn.social({
|
||||
provider: 'google',
|
||||
callbackURL: window.location.origin,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to sign in with Google')
|
||||
setIsGoogleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Use better-auth's magic link sign in
|
||||
const result = await authClient.signIn.magicLink({
|
||||
email,
|
||||
callbackURL: window.location.origin,
|
||||
@@ -45,9 +82,8 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isLoading) {
|
||||
if (!isLoading && !isGoogleLoading) {
|
||||
onOpenChange(false)
|
||||
// Reset state after a short delay to avoid flash
|
||||
setTimeout(() => {
|
||||
setEmail('')
|
||||
setError(null)
|
||||
@@ -56,6 +92,8 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const anyLoading = isLoading || isGoogleLoading
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
@@ -63,7 +101,7 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
||||
<DialogTitle>Sign in to Pascal</DialogTitle>
|
||||
<button
|
||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
|
||||
disabled={isLoading}
|
||||
disabled={anyLoading}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
@@ -95,52 +133,80 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="email">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
autoComplete="email"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isLoading}
|
||||
id="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Google Sign-In */}
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={isLoading || !email}
|
||||
type="submit"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||
disabled={anyLoading}
|
||||
onClick={handleGoogleSignIn}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
|
||||
Sending magic link...
|
||||
</>
|
||||
{isGoogleLoading ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-foreground border-t-transparent" />
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-4 w-4" />
|
||||
Send magic link
|
||||
</>
|
||||
<GoogleIcon className="h-4 w-4" />
|
||||
)}
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-border" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Magic Link Form */}
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm" htmlFor="email">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
autoComplete="email"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={anyLoading}
|
||||
id="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
disabled={anyLoading || !email}
|
||||
type="submit"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
|
||||
Sending magic link...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-4 w-4" />
|
||||
Send magic link
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-muted-foreground text-xs">
|
||||
We'll send you a magic link to sign in without a password.
|
||||
Sign in with Google or receive a magic link via email.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuth } from '../lib/auth/hooks'
|
||||
import { getUsername } from '../lib/auth/actions'
|
||||
import { UsernameOnboardingDialog } from './username-onboarding-dialog'
|
||||
|
||||
export function UsernameGate({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
const [needsUsername, setNeedsUsername] = useState(false)
|
||||
const [checking, setChecking] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return
|
||||
if (!isAuthenticated) {
|
||||
setChecking(false)
|
||||
setNeedsUsername(false)
|
||||
return
|
||||
}
|
||||
getUsername().then((username) => {
|
||||
setNeedsUsername(!username)
|
||||
setChecking(false)
|
||||
})
|
||||
}, [isAuthenticated, isLoading])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<UsernameOnboardingDialog
|
||||
open={needsUsername && !checking}
|
||||
onComplete={() => setNeedsUsername(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
||||
import { updateUsername, checkUsernameAvailability } from '../lib/auth/actions'
|
||||
|
||||
interface UsernameOnboardingDialogProps {
|
||||
open: boolean
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function UsernameOnboardingDialog({ open, onComplete }: UsernameOnboardingDialogProps) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [availability, setAvailability] = useState<'idle' | 'checking' | 'available' | 'taken'>(
|
||||
'idle',
|
||||
)
|
||||
|
||||
const validate = (value: string): string | null => {
|
||||
if (value.length < 3) return 'Must be at least 3 characters'
|
||||
if (value.length > 30) return 'Must be at most 30 characters'
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(value))
|
||||
return 'Only letters, numbers, hyphens, and underscores'
|
||||
return null
|
||||
}
|
||||
|
||||
const checkAvailability = useCallback(async (value: string) => {
|
||||
const validationError = validate(value)
|
||||
if (validationError) {
|
||||
setAvailability('idle')
|
||||
return
|
||||
}
|
||||
setAvailability('checking')
|
||||
const result = await checkUsernameAvailability(value)
|
||||
setAvailability(result.available ? 'available' : 'taken')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!username.trim()) {
|
||||
setAvailability('idle')
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => checkAvailability(username.trim()), 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [username, checkAvailability])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = username.trim()
|
||||
const validationError = validate(trimmed)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setIsSaving(true)
|
||||
|
||||
const result = await updateUsername(trimmed)
|
||||
if (result.success) {
|
||||
onComplete()
|
||||
} else {
|
||||
setError(result.error ?? 'Failed to set username')
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
const validationError = username.trim() ? validate(username.trim()) : null
|
||||
const canSubmit = !isSaving && !validationError && availability === 'available'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent className="sm:max-w-[420px] [&>button]:hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Choose your username</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Pick a public username for the community hub. This will be visible on projects you share.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">
|
||||
@
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder="your-username"
|
||||
className="w-full rounded-md border border-input bg-background pl-7 pr-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSaving}
|
||||
autoFocus
|
||||
minLength={3}
|
||||
maxLength={30}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status indicators */}
|
||||
{username.trim() && !validationError && (
|
||||
<div className="text-xs">
|
||||
{availability === 'checking' && (
|
||||
<span className="text-muted-foreground">Checking availability...</span>
|
||||
)}
|
||||
{availability === 'available' && (
|
||||
<span className="text-green-600 dark:text-green-400">Username is available</span>
|
||||
)}
|
||||
{availability === 'taken' && (
|
||||
<span className="text-destructive">Username is already taken</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{validationError && (
|
||||
<p className="text-destructive text-xs">{validationError}</p>
|
||||
)}
|
||||
{!username.trim() && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
3-30 characters. Letters, numbers, hyphens, and underscores only.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Setting username...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { db, schema } from '@pascal-app/db'
|
||||
import { eq, and, ne, sql } from 'drizzle-orm'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { getSession } from './server'
|
||||
|
||||
/**
|
||||
* Sign in with a social provider (Google)
|
||||
*/
|
||||
export async function signInSocial(provider: 'google', callbackURL?: string) {
|
||||
const result = await auth.api.signInSocial({
|
||||
body: { provider, callbackURL: callbackURL ?? '/' },
|
||||
})
|
||||
revalidatePath('/')
|
||||
if (result.url && result.redirect) {
|
||||
redirect(result.url as '/')
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user's public username
|
||||
*/
|
||||
export async function updateUsername(
|
||||
username: string,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
// Validate username format
|
||||
const trimmed = username.trim()
|
||||
if (trimmed.length < 3) {
|
||||
return { success: false, error: 'Username must be at least 3 characters' }
|
||||
}
|
||||
if (trimmed.length > 30) {
|
||||
return { success: false, error: 'Username must be at most 30 characters' }
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Username can only contain letters, numbers, hyphens, and underscores',
|
||||
}
|
||||
}
|
||||
|
||||
// Check if username is already taken (case-insensitive)
|
||||
const existing = await db
|
||||
.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.where(
|
||||
and(
|
||||
sql`lower(${schema.users.username}) = lower(${trimmed})`,
|
||||
ne(schema.users.id, session.user.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
return { success: false, error: 'Username is already taken' }
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({ username: trimmed })
|
||||
.where(eq(schema.users.id, session.user.id))
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/settings')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user's username
|
||||
*/
|
||||
export async function getUsername(): Promise<string | null> {
|
||||
const session = await getSession()
|
||||
if (!session?.user) return null
|
||||
|
||||
const result = await db
|
||||
.select({ username: schema.users.username })
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
return result[0]?.username ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a username is available
|
||||
*/
|
||||
export async function checkUsernameAvailability(
|
||||
username: string,
|
||||
): Promise<{ available: boolean }> {
|
||||
const trimmed = username.trim()
|
||||
if (trimmed.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
return { available: false }
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.where(sql`lower(${schema.users.username}) = lower(${trimmed})`)
|
||||
.limit(1)
|
||||
|
||||
return { available: existing.length === 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user's full profile
|
||||
*/
|
||||
export async function getUserProfile(): Promise<{
|
||||
username: string | null
|
||||
githubUrl: string | null
|
||||
xUrl: string | null
|
||||
} | null> {
|
||||
const session = await getSession()
|
||||
if (!session?.user) return null
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
username: schema.users.username,
|
||||
githubUrl: schema.users.githubUrl,
|
||||
xUrl: schema.users.xUrl,
|
||||
})
|
||||
.from(schema.users)
|
||||
.where(eq(schema.users.id, session.user.id))
|
||||
.limit(1)
|
||||
|
||||
return result[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the current user's social profile links
|
||||
*/
|
||||
export async function updateProfile(data: {
|
||||
githubUrl?: string | null
|
||||
xUrl?: string | null
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
return { success: false, error: 'Not authenticated' }
|
||||
}
|
||||
|
||||
if (data.githubUrl && !/^https:\/\/(www\.)?github\.com\/.+/.test(data.githubUrl)) {
|
||||
return { success: false, error: 'Invalid GitHub URL' }
|
||||
}
|
||||
if (data.xUrl && !/^https:\/\/(www\.)?(x|twitter)\.com\/.+/.test(data.xUrl)) {
|
||||
return { success: false, error: 'Invalid X/Twitter URL' }
|
||||
}
|
||||
|
||||
await db
|
||||
.update(schema.users)
|
||||
.set({
|
||||
githubUrl: data.githubUrl ?? null,
|
||||
xUrl: data.xUrl ?? null,
|
||||
})
|
||||
.where(eq(schema.users.id, session.user.id))
|
||||
|
||||
revalidatePath('/settings')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user's public profile by username
|
||||
*/
|
||||
export async function getPublicProfile(username: string): Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
id: string
|
||||
name: string
|
||||
image: string | null
|
||||
username: string
|
||||
githubUrl: string | null
|
||||
xUrl: string | null
|
||||
}
|
||||
error?: string
|
||||
}> {
|
||||
const result = await db
|
||||
.select({
|
||||
id: schema.users.id,
|
||||
name: schema.users.name,
|
||||
image: schema.users.image,
|
||||
username: schema.users.username,
|
||||
githubUrl: schema.users.githubUrl,
|
||||
xUrl: schema.users.xUrl,
|
||||
})
|
||||
.from(schema.users)
|
||||
.where(sql`lower(${schema.users.username}) = lower(${username})`)
|
||||
.limit(1)
|
||||
|
||||
const user = result[0]
|
||||
if (!user || !user.username) {
|
||||
return { success: false, error: 'User not found' }
|
||||
}
|
||||
|
||||
return { success: true, data: user as typeof user & { username: string } }
|
||||
}
|
||||
@@ -397,7 +397,8 @@ export async function getPublicProjects(): Promise<ActionResult<Project[]>> {
|
||||
.from('projects')
|
||||
.select(`
|
||||
*,
|
||||
address:projects_addresses(*)
|
||||
address:projects_addresses(*),
|
||||
owner:auth_users!owner_id(id, name, username, image)
|
||||
`)
|
||||
.eq('is_private', false)
|
||||
.order('views', { ascending: false })
|
||||
@@ -424,6 +425,37 @@ export async function getPublicProjects(): Promise<ActionResult<Project[]>> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch public projects for a specific user (by user ID)
|
||||
*/
|
||||
export async function getPublicProjectsByUserId(userId: string): Promise<ActionResult<Project[]>> {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('projects')
|
||||
.select(`
|
||||
*,
|
||||
address:projects_addresses(*)
|
||||
`)
|
||||
.eq('owner_id', userId)
|
||||
.eq('is_private', false)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message, data: [] }
|
||||
}
|
||||
|
||||
return { success: true, data: data as Project[] }
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch projects',
|
||||
data: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a project model for viewing
|
||||
* Allows viewing if: project is public OR user owns the project
|
||||
@@ -440,7 +472,8 @@ export async function getProjectModelPublic(projectId: string): Promise<
|
||||
.from('projects')
|
||||
.select(`
|
||||
*,
|
||||
address:projects_addresses(*)
|
||||
address:projects_addresses(*),
|
||||
owner:auth_users!owner_id(id, name, username, image)
|
||||
`)
|
||||
.eq('id', projectId)
|
||||
.single()
|
||||
|
||||
@@ -87,6 +87,13 @@ export type Database = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ProjectOwner = {
|
||||
id: string
|
||||
name: string
|
||||
username: string | null
|
||||
image: string | null
|
||||
}
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -111,6 +118,7 @@ export type Project = {
|
||||
latitude?: string
|
||||
longitude?: string
|
||||
} | null
|
||||
owner?: ProjectOwner | null
|
||||
}
|
||||
|
||||
export type CreateProjectParams = {
|
||||
|
||||
@@ -12,6 +12,8 @@ export const auth = createAuth({
|
||||
appName: 'Pascal Editor',
|
||||
baseURL: BASE_URL,
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
googleClientId: process.env.GOOGLE_CLIENT_ID,
|
||||
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
if (!resend) {
|
||||
console.log(`[DEV] Magic link for ${email}: ${url}`)
|
||||
|
||||
Reference in New Issue
Block a user