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 { body {
@apply bg-background text-foreground; @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 { .no-scrollbar::-webkit-scrollbar {
+4 -1
View File
@@ -1,5 +1,6 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import localFont from 'next/font/local' import localFont from 'next/font/local'
import { UsernameGate } from '@/features/community/components/username-gate'
import './globals.css' import './globals.css'
const geistSans = localFont({ const geistSans = localFont({
@@ -23,7 +24,9 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>{children}</body> <body className={`${geistSans.variable} ${geistMono.variable}`}>
<UsernameGate>{children}</UsernameGate>
</body>
</html> </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 { ViewerOverlay } from './viewer-overlay'
import { ViewerZoneSystem } from './viewer-zone-system' import { ViewerZoneSystem } from './viewer-zone-system'
import { ThumbnailGenerator } from './thumbnail-generator' import { ThumbnailGenerator } from './thumbnail-generator'
import { ViewerGuestCTA } from './viewer-guest-cta'
import { getProjectModelPublic, incrementProjectViews } from '@/features/community/lib/projects/actions' import { getProjectModelPublic, incrementProjectViews } from '@/features/community/lib/projects/actions'
import type { ProjectOwner } from '@/features/community/lib/projects/types'
export default function ViewerPage() { export default function ViewerPage() {
const params = useParams() const params = useParams()
@@ -16,6 +18,8 @@ export default function ViewerPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [projectId, setProjectId] = 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) const setScene = useScene((state) => state.setScene)
useEffect(() => { useEffect(() => {
@@ -32,6 +36,7 @@ export default function ViewerPage() {
setScene(data.nodes, data.rootNodeIds) setScene(data.nodes, data.rootNodeIds)
initSpatialGridSync() initSpatialGridSync()
} }
setProjectName('Demo')
} else { } else {
// Load from database (public project) // Load from database (public project)
const result = await getProjectModelPublic(id) const result = await getProjectModelPublic(id)
@@ -39,6 +44,8 @@ export default function ViewerPage() {
if (result.success && result.data) { if (result.success && result.data) {
const { project, model } = result.data const { project, model } = result.data
setProjectId(project.id) setProjectId(project.id)
setProjectName(project.name)
setOwner((project as any).owner ?? null)
if (model?.scene_graph) { if (model?.scene_graph) {
const { nodes, rootNodeIds } = model.scene_graph const { nodes, rootNodeIds } = model.scene_graph
@@ -81,13 +88,11 @@ export default function ViewerPage() {
return ( return (
<div className="relative h-screen w-full"> <div className="relative h-screen w-full">
<ViewerOverlay /> <ViewerOverlay projectName={projectName} owner={owner} />
<ViewerGuestCTA />
<Viewer> <Viewer>
{/* Custom Camera Controls */}
<ViewerCameraControls /> <ViewerCameraControls />
{/* Custom Zone System */}
<ViewerZoneSystem /> <ViewerZoneSystem />
{/* Thumbnail Generator */}
<ThumbnailGenerator projectId={projectId || undefined} /> <ThumbnailGenerator projectId={projectId || undefined} />
</Viewer> </Viewer>
</div> </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 { type AnyNode, type AnyNodeId, type BuildingNode, type LevelNode, type ZoneNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' 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 => { const getNodeName = (node: AnyNode): string => {
if ('name' in node && node.name) return node.name if ('name' in node && node.name) return node.name
@@ -14,7 +16,12 @@ const getNodeName = (node: AnyNode): string => {
return node.type 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 selection = useViewer((s) => s.selection)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
const showScans = useViewer((s) => s.showScans) const showScans = useViewer((s) => s.showScans)
@@ -59,60 +66,90 @@ export const ViewerOverlay = () => {
return ( return (
<> <>
{/* Unified top-left card */}
<div className="absolute top-4 left-4 z-10 flex flex-col gap-3"> <div className="absolute top-4 left-4 z-10 flex flex-col gap-3">
{/* Breadcrumb */} <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">
<div className="flex items-center gap-1 text-sm"> {/* Project info + back */}
<button <div className="flex items-center gap-3 px-3 py-2">
onClick={() => handleBreadcrumbClick('root')} <Link
className="text-neutral-500 hover:text-neutral-800 transition-colors" href="/"
> className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 transition-colors"
Site >
</button> <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 && ( {building && (
<> <div className="border-t border-neutral-100 px-3 py-1.5">
<ChevronRight className="w-4 h-4 text-neutral-400" /> <div className="flex items-center gap-1 text-xs">
<button <button
onClick={() => handleBreadcrumbClick('building')} onClick={() => handleBreadcrumbClick('root')}
className={`transition-colors ${level ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`} className="text-neutral-500 hover:text-neutral-800 transition-colors"
> >
{building.name || 'Building'} Site
</button> </button>
</>
)}
{level && ( {building && (
<> <>
<ChevronRight className="w-4 h-4 text-neutral-400" /> <ChevronRight className="w-3 h-3 text-neutral-400" />
<button <button
onClick={() => handleBreadcrumbClick('level')} onClick={() => handleBreadcrumbClick('building')}
className={`transition-colors ${zone ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`} className={`transition-colors truncate ${level ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
> >
{level.name || `Level ${level.level}`} {building.name || 'Building'}
</button> </button>
</> </>
)} )}
{zone && ( {level && (
<> <>
<ChevronRight className="w-4 h-4 text-neutral-400" /> <ChevronRight className="w-3 h-3 text-neutral-400" />
<span className={`transition-colors ${selectedNode ? 'text-neutral-500' : 'text-neutral-800 font-medium'}`}> <button
{zone.name} onClick={() => handleBreadcrumbClick('level')}
</span> 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 && ( {zone && (
<> <>
<ChevronRight className="w-4 h-4 text-neutral-400" /> <ChevronRight className="w-3 h-3 text-neutral-400" />
<span className="text-neutral-800 font-medium">{getNodeName(selectedNode)}</span> <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> </div>
{/* Level List (only when building is selected) */} {/* Level List (only when building is selected) */}
{building && levels.length > 0 && ( {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> <span className="text-xs text-neutral-500 px-2 pb-1">Levels</span>
{levels.map((lvl) => ( {levels.map((lvl) => (
<button <button
@@ -134,7 +171,7 @@ export const ViewerOverlay = () => {
{/* Controls Panel - Top Right */} {/* Controls Panel - Top Right */}
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2"> <div className="absolute top-4 right-4 z-10 flex flex-col gap-2">
{/* Visibility Controls */} {/* 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> <span className="text-xs text-neutral-500 px-2 pb-1">Visibility</span>
<button <button
onClick={() => useViewer.getState().setShowScans(!showScans)} onClick={() => useViewer.getState().setShowScans(!showScans)}
@@ -157,7 +194,7 @@ export const ViewerOverlay = () => {
</div> </div>
{/* Camera Mode */} {/* 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> <span className="text-xs text-neutral-500 px-2 pb-1">Camera</span>
<button <button
onClick={() => useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')} onClick={() => useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
@@ -173,7 +210,7 @@ export const ViewerOverlay = () => {
</div> </div>
{/* Level Mode */} {/* 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> <span className="text-xs text-neutral-500 px-2 pb-1">Level Mode</span>
<button <button
onClick={() => useViewer.getState().setLevelMode('stacked')} onClick={() => useViewer.getState().setLevelMode('stacked')}
@@ -205,7 +242,7 @@ export const ViewerOverlay = () => {
</div> </div>
{/* Wall Mode */} {/* 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> <span className="text-xs text-neutral-500 px-2 pb-1">Wall Mode</span>
<button <button
onClick={() => useViewer.getState().setWallMode('cutaway')} 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 { getPublicProjects, getUserProjects } from '../lib/projects/actions'
import type { Project } from '../lib/projects/types' import type { Project } from '../lib/projects/types'
import { CreateProjectButton } from './create-project-button' import { CreateProjectButton } from './create-project-button'
import { HubFooter } from './hub-footer'
import { NewProjectDialog } from './new-project-dialog' import { NewProjectDialog } from './new-project-dialog'
import { ProfileDropdown } from './profile-dropdown' import { ProfileDropdown } from './profile-dropdown'
import { ProjectGrid } from './project-grid' import { ProjectGrid } from './project-grid'
@@ -119,16 +120,28 @@ export default function CommunityHub() {
/> />
<h1 className="text-2xl font-bold">Hub</h1> <h1 className="text-2xl font-bold">Hub</h1>
</div> </div>
{!isAuthenticated ? ( <div className="flex items-center gap-3">
<button <a
onClick={() => setIsSignInDialogOpen(true)} href="https://github.com/pascalorg/editor"
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90" 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 <svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
</button> <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>
<ProfileDropdown /> </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>
</div> </div>
</header> </header>
@@ -209,6 +222,8 @@ export default function CommunityHub() {
</section> </section>
</main> </main>
<HubFooter />
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} /> <SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
<NewProjectDialog <NewProjectDialog
open={isNewProjectDialogOpen} open={isNewProjectDialogOpen}
@@ -10,7 +10,7 @@ export function CreateProjectButton({ onCreateProject }: CreateProjectButtonProp
return ( return (
<button <button
onClick={onCreateProject} 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" /> <Plus className="w-4 h-4" />
<span>Create Project</span> <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' 'use client'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { useAuth } from '../lib/auth/hooks' import { useAuth } from '../lib/auth/hooks'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/primitives/dropdown-menu' } 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() { export function ProfileDropdown() {
const { user, signOut } = useAuth() const { user, signOut } = useAuth()
const router = useRouter()
const handleSignOut = async () => { const handleSignOut = async () => {
await signOut() 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' const initials = user?.name ? getInitials(user.name) : user?.email?.[0]?.toUpperCase() || 'U'
@@ -35,20 +37,49 @@ export function ProfileDropdown() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <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" 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> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className="w-56">
{user?.name && ( <div className="flex items-center gap-3 px-2 py-2">
<div className="px-2 py-1.5 text-sm"> {user?.image ? (
<div className="font-medium">{user.name}</div> <Image
{user.email && <div className="text-muted-foreground text-xs">{user.email}</div>} 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> </div>
)} </div>
{user?.name && <DropdownMenuItem className="h-px bg-border" />} <DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" onClick={() => router.push('/settings')}>
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" variant="destructive" onClick={handleSignOut}> <DropdownMenuItem className="cursor-pointer" variant="destructive" onClick={handleSignOut}>
Sign out Sign out
</DropdownMenuItem> </DropdownMenuItem>
@@ -1,6 +1,7 @@
'use client' 'use client'
import { Eye, Heart, Settings } from 'lucide-react' import { Eye, Heart, Settings } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { Project } from '../lib/projects/types' import type { Project } from '../lib/projects/types'
import type { LocalProject } from '../lib/local-storage/project-store' import type { LocalProject } from '../lib/local-storage/project-store'
@@ -116,105 +117,144 @@ export function ProjectGrid({
return ( return (
<> <>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
{projects.map((project) => ( {projects.map((project) => {
<div const owner = !isLocalProject(project) ? project.owner : null
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>
{/* Info */} return (
<div className="p-4"> <div
<h3 className="font-medium text-left line-clamp-2 mb-2">{project.name}</h3> key={project.id}
onClick={() => onProjectClick(project.id)}
{!isLocalProject(project) && ( className="group text-left cursor-pointer"
<div className="flex items-center gap-4 text-sm text-muted-foreground"> >
<div className="flex items-center gap-1"> {/* Thumbnail card */}
<Eye className="w-4 h-4" /> <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)]">
<span>{project.views}</span> {!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> </div>
<button )}
onClick={(e) => handleLikeClick(e, project.id)} {isLocalProject(project) && (
className="flex items-center gap-1 hover:text-red-500 transition-colors" <div className="absolute top-3 right-3">
disabled={!isAuthenticated} {isAuthenticated && onSaveToCloud ? (
> <button
<Heart onClick={(e) => {
className={`w-4 h-4 ${ e.stopPropagation()
userLikes[project.id] onSaveToCloud(project)
? 'fill-red-500 text-red-500' }}
: '' 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"
/> >
<span>{likeCounts[project.id] ?? project.likes}</span> Save to cloud
</button> </button>
</div> ) : (
)} <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) && ( {/* Info row below the card */}
<div className="text-sm text-muted-foreground"> <div className="flex items-center gap-3 mt-3">
{new Date(project.updated_at).toLocaleDateString()} {/* 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>
</div> )
))} })}
</div> </div>
{/* Settings Dialog */} {/* 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 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) { export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false) 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError(null) setError(null)
setIsLoading(true) setIsLoading(true)
try { try {
// Use better-auth's magic link sign in
const result = await authClient.signIn.magicLink({ const result = await authClient.signIn.magicLink({
email, email,
callbackURL: window.location.origin, callbackURL: window.location.origin,
@@ -45,9 +82,8 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
} }
const handleClose = () => { const handleClose = () => {
if (!isLoading) { if (!isLoading && !isGoogleLoading) {
onOpenChange(false) onOpenChange(false)
// Reset state after a short delay to avoid flash
setTimeout(() => { setTimeout(() => {
setEmail('') setEmail('')
setError(null) setError(null)
@@ -56,6 +92,8 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
} }
} }
const anyLoading = isLoading || isGoogleLoading
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[400px]"> <DialogContent className="sm:max-w-[400px]">
@@ -63,7 +101,7 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
<DialogTitle>Sign in to Pascal</DialogTitle> <DialogTitle>Sign in to Pascal</DialogTitle>
<button <button
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none" 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} onClick={handleClose}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@@ -95,52 +133,80 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
</button> </button>
</div> </div>
) : ( ) : (
<form className="space-y-4" onSubmit={handleSubmit}> <div className="space-y-4">
<div className="space-y-2"> {/* Google Sign-In */}
<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>
)}
<button <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" 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={isLoading || !email} disabled={anyLoading}
type="submit" onClick={handleGoogleSignIn}
type="button"
> >
{isLoading ? ( {isGoogleLoading ? (
<> <div className="h-4 w-4 animate-spin rounded-full border-2 border-foreground border-t-transparent" />
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
Sending magic link...
</>
) : ( ) : (
<> <GoogleIcon className="h-4 w-4" />
<Mail className="h-4 w-4" />
Send magic link
</>
)} )}
Continue with Google
</button> </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"> <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> </p>
</form> </div>
)} )}
</DialogContent> </DialogContent>
</Dialog> </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') .from('projects')
.select(` .select(`
*, *,
address:projects_addresses(*) address:projects_addresses(*),
owner:auth_users!owner_id(id, name, username, image)
`) `)
.eq('is_private', false) .eq('is_private', false)
.order('views', { ascending: 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 * Get a project model for viewing
* Allows viewing if: project is public OR user owns the project * Allows viewing if: project is public OR user owns the project
@@ -440,7 +472,8 @@ export async function getProjectModelPublic(projectId: string): Promise<
.from('projects') .from('projects')
.select(` .select(`
*, *,
address:projects_addresses(*) address:projects_addresses(*),
owner:auth_users!owner_id(id, name, username, image)
`) `)
.eq('id', projectId) .eq('id', projectId)
.single() .single()
@@ -87,6 +87,13 @@ export type Database = {
} }
} }
export type ProjectOwner = {
id: string
name: string
username: string | null
image: string | null
}
export type Project = { export type Project = {
id: string id: string
name: string name: string
@@ -111,6 +118,7 @@ export type Project = {
latitude?: string latitude?: string
longitude?: string longitude?: string
} | null } | null
owner?: ProjectOwner | null
} }
export type CreateProjectParams = { export type CreateProjectParams = {
+2
View File
@@ -12,6 +12,8 @@ export const auth = createAuth({
appName: 'Pascal Editor', appName: 'Pascal Editor',
baseURL: BASE_URL, baseURL: BASE_URL,
secret: process.env.BETTER_AUTH_SECRET!, secret: process.env.BETTER_AUTH_SECRET!,
googleClientId: process.env.GOOGLE_CLIENT_ID,
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
sendMagicLink: async ({ email, url }) => { sendMagicLink: async ({ email, url }) => {
if (!resend) { if (!resend) {
console.log(`[DEV] Magic link for ${email}: ${url}`) console.log(`[DEV] Magic link for ${email}: ${url}`)
+4
View File
@@ -12,6 +12,10 @@
"check:fix": "biome check --write", "check:fix": "biome check --write",
"check-types": "turbo run check-types", "check-types": "turbo run check-types",
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'", "kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'",
"db:generate": "bun run --cwd packages/db db:generate",
"db:migrate": "bun run --cwd packages/db db:migrate",
"db:push": "bun run --cwd packages/db db:push",
"db:studio": "bun run --cwd packages/db db:studio",
"db:start": "supabase start", "db:start": "supabase start",
"db:stop": "supabase stop", "db:stop": "supabase stop",
"db:reset": "supabase db reset", "db:reset": "supabase db reset",
+20
View File
@@ -16,6 +16,10 @@ export interface AuthConfig {
appName: string appName: string
baseURL: string baseURL: string
secret: string secret: string
/** Google OAuth client ID */
googleClientId?: string
/** Google OAuth client secret */
googleClientSecret?: string
/** Callback to send magic link emails */ /** Callback to send magic link emails */
sendMagicLink?: (params: SendMagicLinkParams) => Promise<void> sendMagicLink?: (params: SendMagicLinkParams) => Promise<void>
/** Additional plugins to add (e.g., nextCookies for web) */ /** Additional plugins to add (e.g., nextCookies for web) */
@@ -57,6 +61,22 @@ export function createAuth(config: AuthConfig): ReturnType<typeof betterAuth> {
}, },
}, },
}, },
// Google OAuth provider (only enabled when credentials are provided)
...(config.googleClientId &&
config.googleClientSecret && {
socialProviders: {
google: {
clientId: config.googleClientId,
clientSecret: config.googleClientSecret,
},
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ['google'],
},
},
}),
plugins: [ plugins: [
...(config.additionalPlugins ?? []), ...(config.additionalPlugins ?? []),
// Magic link authentication // Magic link authentication
+10 -1
View File
@@ -13,13 +13,22 @@ export const users = pgTable(
emailVerified: t.boolean('email_verified').notNull().default(false), emailVerified: t.boolean('email_verified').notNull().default(false),
name: t.text('name').notNull(), name: t.text('name').notNull(),
image: t.text('image'), image: t.text('image'),
/** Public username for the community hub */
username: t.text('username'),
/** GitHub profile URL */
githubUrl: t.text('github_url'),
/** X/Twitter profile URL */
xUrl: t.text('x_url'),
role: userRoles('role').notNull().default('user'), role: userRoles('role').notNull().default('user'),
banned: t.boolean('banned').notNull().default(false), banned: t.boolean('banned').notNull().default(false),
banReason: t.text('ban_reason'), banReason: t.text('ban_reason'),
banExpires: t.timestamp('ban_expires', { withTimezone: true }), banExpires: t.timestamp('ban_expires', { withTimezone: true }),
...timestampsColumns, ...timestampsColumns,
}), }),
(t) => [uniqueIndex('email_unique_index').on(lower(t.email))], (t) => [
uniqueIndex('email_unique_index').on(lower(t.email)),
uniqueIndex('username_unique_index').on(lower(t.username)),
],
).enableRLS() ).enableRLS()
export type User = typeof users.$inferSelect export type User = typeof users.$inferSelect
@@ -0,0 +1,2 @@
ALTER TABLE "auth_users" ADD COLUMN "username" text;--> statement-breakpoint
CREATE UNIQUE INDEX "username_unique_index" ON "auth_users" USING btree (lower("username"));
@@ -0,0 +1,2 @@
ALTER TABLE "auth_users" ADD COLUMN "github_url" text;--> statement-breakpoint
ALTER TABLE "auth_users" ADD COLUMN "x_url" text;
@@ -0,0 +1,967 @@
{
"id": "a70e4c16-59a9-4c80-81db-fb43206f7eeb",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.auth_accounts": {
"name": "auth_accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"auth_accounts_user_id_auth_users_id_fk": {
"name": "auth_accounts_user_id_auth_users_id_fk",
"tableFrom": "auth_accounts",
"tableTo": "auth_users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.auth_jwks": {
"name": "auth_jwks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key": {
"name": "private_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.auth_sessions": {
"name": "auth_sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"active_project_id": {
"name": "active_project_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"auth_sessions_user_id_auth_users_id_fk": {
"name": "auth_sessions_user_id_auth_users_id_fk",
"tableFrom": "auth_sessions",
"tableTo": "auth_users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"auth_sessions_impersonated_by_auth_users_id_fk": {
"name": "auth_sessions_impersonated_by_auth_users_id_fk",
"tableFrom": "auth_sessions",
"tableTo": "auth_users",
"columnsFrom": [
"impersonated_by"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.auth_users": {
"name": "auth_users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "auth_user_roles",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"email_unique_index": {
"name": "email_unique_index",
"columns": [
{
"expression": "lower(\"email\")",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.auth_verifications": {
"name": "auth_verifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier_index": {
"name": "verification_identifier_index",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.feedback": {
"name": "feedback",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.projects_addresses": {
"name": "projects_addresses",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"street_number": {
"name": "street_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"route": {
"name": "route",
"type": "text",
"primaryKey": false,
"notNull": false
},
"route_short": {
"name": "route_short",
"type": "text",
"primaryKey": false,
"notNull": false
},
"neighborhood": {
"name": "neighborhood",
"type": "text",
"primaryKey": false,
"notNull": false
},
"city": {
"name": "city",
"type": "text",
"primaryKey": false,
"notNull": false
},
"county": {
"name": "county",
"type": "text",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": false
},
"state_long": {
"name": "state_long",
"type": "text",
"primaryKey": false,
"notNull": false
},
"postal_code": {
"name": "postal_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"postal_code_suffix": {
"name": "postal_code_suffix",
"type": "text",
"primaryKey": false,
"notNull": false
},
"country": {
"name": "country",
"type": "text",
"primaryKey": false,
"notNull": false
},
"country_long": {
"name": "country_long",
"type": "text",
"primaryKey": false,
"notNull": false
},
"latitude": {
"name": "latitude",
"type": "numeric",
"primaryKey": false,
"notNull": false
},
"longitude": {
"name": "longitude",
"type": "numeric",
"primaryKey": false,
"notNull": false
},
"raw_json": {
"name": "raw_json",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"address_components_unique": {
"name": "address_components_unique",
"nullsNotDistinct": false,
"columns": [
"street_number",
"route",
"city",
"state",
"postal_code"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.projects_likes": {
"name": "projects_likes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"projects_likes_project_id_projects_id_fk": {
"name": "projects_likes_project_id_projects_id_fk",
"tableFrom": "projects_likes",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"projects_likes_project_user_unique": {
"name": "projects_likes_project_user_unique",
"nullsNotDistinct": false,
"columns": [
"project_id",
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.projects_models": {
"name": "projects_models",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 1
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"draft": {
"name": "draft",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scene_graph": {
"name": "scene_graph",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"projects_models_project_id_projects_id_fk": {
"name": "projects_models_project_id_projects_id_fk",
"tableFrom": "projects_models",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.projects": {
"name": "projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"address_id": {
"name": "address_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"details_json": {
"name": "details_json",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"is_private": {
"name": "is_private",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"views": {
"name": "views",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"likes": {
"name": "likes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"project_address_idx": {
"name": "project_address_idx",
"columns": [
{
"expression": "address_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"project_owner_idx": {
"name": "project_owner_idx",
"columns": [
{
"expression": "owner_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"project_is_private_idx": {
"name": "project_is_private_idx",
"columns": [
{
"expression": "is_private",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"project_views_idx": {
"name": "project_views_idx",
"columns": [
{
"expression": "views",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"project_likes_idx": {
"name": "project_likes_idx",
"columns": [
{
"expression": "likes",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"projects_address_id_projects_addresses_id_fk": {
"name": "projects_address_id_projects_addresses_id_fk",
"tableFrom": "projects",
"tableTo": "projects_addresses",
"columnsFrom": [
"address_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"projects_owner_id_auth_users_id_fk": {
"name": "projects_owner_id_auth_users_id_fk",
"tableFrom": "projects",
"tableTo": "auth_users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
}
},
"enums": {
"public.auth_user_roles": {
"name": "auth_user_roles",
"schema": "public",
"values": [
"user",
"admin"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -0,0 +1,988 @@
{
"id": "a414a135-fe6d-4192-9d7c-52a09abead20",
"prevId": "a70e4c16-59a9-4c80-81db-fb43206f7eeb",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.auth_accounts": {
"name": "auth_accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"auth_accounts_user_id_auth_users_id_fk": {
"name": "auth_accounts_user_id_auth_users_id_fk",
"tableFrom": "auth_accounts",
"tableTo": "auth_users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.auth_jwks": {
"name": "auth_jwks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key": {
"name": "private_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.auth_sessions": {
"name": "auth_sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"active_project_id": {
"name": "active_project_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"auth_sessions_user_id_auth_users_id_fk": {
"name": "auth_sessions_user_id_auth_users_id_fk",
"tableFrom": "auth_sessions",
"tableTo": "auth_users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"auth_sessions_impersonated_by_auth_users_id_fk": {
"name": "auth_sessions_impersonated_by_auth_users_id_fk",
"tableFrom": "auth_sessions",
"tableTo": "auth_users",
"columnsFrom": [
"impersonated_by"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.auth_users": {
"name": "auth_users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "auth_user_roles",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"email_unique_index": {
"name": "email_unique_index",
"columns": [
{
"expression": "lower(\"email\")",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"username_unique_index": {
"name": "username_unique_index",
"columns": [
{
"expression": "lower(\"username\")",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.auth_verifications": {
"name": "auth_verifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier_index": {
"name": "verification_identifier_index",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.feedback": {
"name": "feedback",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.projects_addresses": {
"name": "projects_addresses",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"street_number": {
"name": "street_number",
"type": "text",
"primaryKey": false,
"notNull": false
},
"route": {
"name": "route",
"type": "text",
"primaryKey": false,
"notNull": false
},
"route_short": {
"name": "route_short",
"type": "text",
"primaryKey": false,
"notNull": false
},
"neighborhood": {
"name": "neighborhood",
"type": "text",
"primaryKey": false,
"notNull": false
},
"city": {
"name": "city",
"type": "text",
"primaryKey": false,
"notNull": false
},
"county": {
"name": "county",
"type": "text",
"primaryKey": false,
"notNull": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": false
},
"state_long": {
"name": "state_long",
"type": "text",
"primaryKey": false,
"notNull": false
},
"postal_code": {
"name": "postal_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"postal_code_suffix": {
"name": "postal_code_suffix",
"type": "text",
"primaryKey": false,
"notNull": false
},
"country": {
"name": "country",
"type": "text",
"primaryKey": false,
"notNull": false
},
"country_long": {
"name": "country_long",
"type": "text",
"primaryKey": false,
"notNull": false
},
"latitude": {
"name": "latitude",
"type": "numeric",
"primaryKey": false,
"notNull": false
},
"longitude": {
"name": "longitude",
"type": "numeric",
"primaryKey": false,
"notNull": false
},
"raw_json": {
"name": "raw_json",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"address_components_unique": {
"name": "address_components_unique",
"nullsNotDistinct": false,
"columns": [
"street_number",
"route",
"city",
"state",
"postal_code"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.projects_likes": {
"name": "projects_likes",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"projects_likes_project_id_projects_id_fk": {
"name": "projects_likes_project_id_projects_id_fk",
"tableFrom": "projects_likes",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"projects_likes_project_user_unique": {
"name": "projects_likes_project_user_unique",
"nullsNotDistinct": false,
"columns": [
"project_id",
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.projects_models": {
"name": "projects_models",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 1
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"draft": {
"name": "draft",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scene_graph": {
"name": "scene_graph",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"projects_models_project_id_projects_id_fk": {
"name": "projects_models_project_id_projects_id_fk",
"tableFrom": "projects_models",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
},
"public.projects": {
"name": "projects",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"address_id": {
"name": "address_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"details_json": {
"name": "details_json",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"is_private": {
"name": "is_private",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"views": {
"name": "views",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"likes": {
"name": "likes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"project_address_idx": {
"name": "project_address_idx",
"columns": [
{
"expression": "address_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"project_owner_idx": {
"name": "project_owner_idx",
"columns": [
{
"expression": "owner_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"project_is_private_idx": {
"name": "project_is_private_idx",
"columns": [
{
"expression": "is_private",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"project_views_idx": {
"name": "project_views_idx",
"columns": [
{
"expression": "views",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"project_likes_idx": {
"name": "project_likes_idx",
"columns": [
{
"expression": "likes",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"projects_address_id_projects_addresses_id_fk": {
"name": "projects_address_id_projects_addresses_id_fk",
"tableFrom": "projects",
"tableTo": "projects_addresses",
"columnsFrom": [
"address_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"projects_owner_id_auth_users_id_fk": {
"name": "projects_owner_id_auth_users_id_fk",
"tableFrom": "projects",
"tableTo": "auth_users",
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": true
}
},
"enums": {
"public.auth_user_roles": {
"name": "auth_user_roles",
"schema": "public",
"values": [
"user",
"admin"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1771477810895,
"tag": "20260219051010_baseline",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1771477819937,
"tag": "20260219051019_add-username-field",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1771478645202,
"tag": "20260219052405_add-social-profile-fields",
"breakpoints": true
}
]
}