diff --git a/apps/editor/app/globals.css b/apps/editor/app/globals.css index 8a387107..09ca6b85 100644 --- a/apps/editor/app/globals.css +++ b/apps/editor/app/globals.css @@ -119,6 +119,21 @@ body { @apply bg-background text-foreground; } + button, + [role="button"], + a { + cursor: pointer; + } +} + +/* Apple-style smooth corners (squircle) — progressive enhancement */ +.rounded-smooth { + border-radius: var(--radius-lg); + corner-shape: squircle; +} +.rounded-smooth-xl { + border-radius: var(--radius-xl); + corner-shape: squircle; } .no-scrollbar::-webkit-scrollbar { diff --git a/apps/editor/app/layout.tsx b/apps/editor/app/layout.tsx index 2e571934..65820005 100644 --- a/apps/editor/app/layout.tsx +++ b/apps/editor/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next' import localFont from 'next/font/local' +import { UsernameGate } from '@/features/community/components/username-gate' import './globals.css' const geistSans = localFont({ @@ -23,7 +24,9 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + ) } diff --git a/apps/editor/app/settings/page.tsx b/apps/editor/app/settings/page.tsx new file mode 100644 index 00000000..bae5a2df --- /dev/null +++ b/apps/editor/app/settings/page.tsx @@ -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 ( + + ) +} diff --git a/apps/editor/app/u/[username]/page.tsx b/apps/editor/app/u/[username]/page.tsx new file mode 100644 index 00000000..135e6f42 --- /dev/null +++ b/apps/editor/app/u/[username]/page.tsx @@ -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 ( + + ) +} diff --git a/apps/editor/app/viewer/[id]/page.tsx b/apps/editor/app/viewer/[id]/page.tsx index ea0d89aa..1235f3cf 100644 --- a/apps/editor/app/viewer/[id]/page.tsx +++ b/apps/editor/app/viewer/[id]/page.tsx @@ -8,7 +8,9 @@ import { ViewerCameraControls } from './viewer-camera-controls' import { ViewerOverlay } from './viewer-overlay' import { ViewerZoneSystem } from './viewer-zone-system' import { ThumbnailGenerator } from './thumbnail-generator' +import { ViewerGuestCTA } from './viewer-guest-cta' import { getProjectModelPublic, incrementProjectViews } from '@/features/community/lib/projects/actions' +import type { ProjectOwner } from '@/features/community/lib/projects/types' export default function ViewerPage() { const params = useParams() @@ -16,6 +18,8 @@ export default function ViewerPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [projectId, setProjectId] = useState(null) + const [projectName, setProjectName] = useState(null) + const [owner, setOwner] = useState(null) const setScene = useScene((state) => state.setScene) useEffect(() => { @@ -32,6 +36,7 @@ export default function ViewerPage() { setScene(data.nodes, data.rootNodeIds) initSpatialGridSync() } + setProjectName('Demo') } else { // Load from database (public project) const result = await getProjectModelPublic(id) @@ -39,6 +44,8 @@ export default function ViewerPage() { if (result.success && result.data) { const { project, model } = result.data setProjectId(project.id) + setProjectName(project.name) + setOwner((project as any).owner ?? null) if (model?.scene_graph) { const { nodes, rootNodeIds } = model.scene_graph @@ -81,13 +88,11 @@ export default function ViewerPage() { return (
- + + - {/* Custom Camera Controls */} - {/* Custom Zone System */} - {/* Thumbnail Generator */}
diff --git a/apps/editor/app/viewer/[id]/viewer-guest-cta.tsx b/apps/editor/app/viewer/[id]/viewer-guest-cta.tsx new file mode 100644 index 00000000..e0de0c87 --- /dev/null +++ b/apps/editor/app/viewer/[id]/viewer-guest-cta.tsx @@ -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 ( + <> +
+
+

Want to create your own 3D project?

+ +
+
+ + + ) +} diff --git a/apps/editor/app/viewer/[id]/viewer-overlay.tsx b/apps/editor/app/viewer/[id]/viewer-overlay.tsx index 0dc17b6b..a46032fb 100644 --- a/apps/editor/app/viewer/[id]/viewer-overlay.tsx +++ b/apps/editor/app/viewer/[id]/viewer-overlay.tsx @@ -2,7 +2,9 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, type LevelNode, type ZoneNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { Box, ChevronRight, Diamond, Eye, EyeOff, Image, Layers, Layers2 } from 'lucide-react' +import Link from 'next/link' +import { ArrowLeft, Box, ChevronRight, Diamond, Eye, EyeOff, Image, Layers, Layers2 } from 'lucide-react' +import type { ProjectOwner } from '@/features/community/lib/projects/types' const getNodeName = (node: AnyNode): string => { if ('name' in node && node.name) return node.name @@ -14,7 +16,12 @@ const getNodeName = (node: AnyNode): string => { return node.type } -export const ViewerOverlay = () => { +interface ViewerOverlayProps { + projectName?: string | null + owner?: ProjectOwner | null +} + +export const ViewerOverlay = ({ projectName, owner }: ViewerOverlayProps) => { const selection = useViewer((s) => s.selection) const nodes = useScene((s) => s.nodes) const showScans = useViewer((s) => s.showScans) @@ -59,60 +66,90 @@ export const ViewerOverlay = () => { return ( <> + {/* Unified top-left card */}
- {/* Breadcrumb */} -
- +
+ {/* Project info + back */} +
+ + + +
+
+ {projectName || 'Untitled'} +
+ {owner?.username && ( + + @{owner.username} + + )} +
+
+ {/* Breadcrumb — only shown when navigated into a building */} {building && ( - <> - +
+
- - )} - {level && ( - <> - - - - )} + {building && ( + <> + + + + )} - {zone && ( - <> - - - {zone.name} - - - )} + {level && ( + <> + + + + )} - {selectedNode && zone && ( - <> - - {getNodeName(selectedNode)} - + {zone && ( + <> + + + {zone.name} + + + )} + + {selectedNode && zone && ( + <> + + {getNodeName(selectedNode)} + + )} +
+
)}
{/* Level List (only when building is selected) */} {building && levels.length > 0 && ( -
+
Levels {levels.map((lvl) => (
{/* Camera Mode */} -
+
Camera
{/* Level Mode */} -
+
Level Mode
{/* Wall Mode */} -
+
Wall Mode
- {!isAuthenticated ? ( - - ) : ( - - )} + + + + + {!isAuthenticated ? ( + + ) : ( + + )} +
@@ -209,6 +222,8 @@ export default function CommunityHub() { + + Create Project diff --git a/apps/editor/features/community/components/hub-footer.tsx b/apps/editor/features/community/components/hub-footer.tsx new file mode 100644 index 00000000..86ba5209 --- /dev/null +++ b/apps/editor/features/community/components/hub-footer.tsx @@ -0,0 +1,69 @@ +import Link from 'next/link' + +function GitHubIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +function NpmIcon({ className }: { className?: string }) { + return ( + + + + + ) +} + +export function HubFooter() { + return ( + + ) +} diff --git a/apps/editor/features/community/components/profile-dropdown.tsx b/apps/editor/features/community/components/profile-dropdown.tsx index 6071b2e2..b4653f5f 100644 --- a/apps/editor/features/community/components/profile-dropdown.tsx +++ b/apps/editor/features/community/components/profile-dropdown.tsx @@ -1,10 +1,13 @@ 'use client' +import Image from 'next/image' +import { useRouter } from 'next/navigation' import { useAuth } from '../lib/auth/hooks' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/primitives/dropdown-menu' @@ -18,15 +21,14 @@ function getInitials(name: string): string { } /** - * ProfileDropdown - User profile menu with sign out + * ProfileDropdown - User profile menu with avatar, settings, and sign out */ export function ProfileDropdown() { const { user, signOut } = useAuth() + const router = useRouter() const handleSignOut = async () => { await signOut() - // TODO: Show sign-in dialog or redirect - console.log('Signed out') } const initials = user?.name ? getInitials(user.name) : user?.email?.[0]?.toUpperCase() || 'U' @@ -35,20 +37,49 @@ export function ProfileDropdown() { - - {user?.name && ( -
-
{user.name}
- {user.email &&
{user.email}
} + +
+ {user?.image ? ( + {user.name + ) : ( +
+ {initials} +
+ )} +
+ {user?.name &&
{user.name}
} + {user?.email && ( +
{user.email}
+ )}
- )} - {user?.name && } +
+ + router.push('/settings')}> + Settings + + Sign out diff --git a/apps/editor/features/community/components/project-grid.tsx b/apps/editor/features/community/components/project-grid.tsx index 92b38c9f..d4e785bc 100644 --- a/apps/editor/features/community/components/project-grid.tsx +++ b/apps/editor/features/community/components/project-grid.tsx @@ -1,6 +1,7 @@ 'use client' import { Eye, Heart, Settings } from 'lucide-react' +import Link from 'next/link' import { useEffect, useState } from 'react' import type { Project } from '../lib/projects/types' import type { LocalProject } from '../lib/local-storage/project-store' @@ -116,105 +117,144 @@ export function ProjectGrid({ return ( <> -
- {projects.map((project) => ( -
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 */} -
- {!isLocalProject(project) && project.thumbnail_url ? ( - {project.name} - ) : ( -
- No preview -
- )} - {isLocalProject(project) && ( -
- {isAuthenticated && onSaveToCloud ? ( - - ) : ( -
- Local -
- )} -
- )} - {canEdit && !isLocalProject(project) && ( -
- {onViewClick && ( - - )} - -
- )} -
+
+ {projects.map((project) => { + const owner = !isLocalProject(project) ? project.owner : null - {/* Info */} -
-

{project.name}

- - {!isLocalProject(project) && ( -
-
- - {project.views} + return ( +
onProjectClick(project.id)} + className="group text-left cursor-pointer" + > + {/* Thumbnail card */} +
+ {!isLocalProject(project) && project.thumbnail_url ? ( + {project.name} + ) : ( +
+ No preview
- -
- )} + )} + {isLocalProject(project) && ( +
+ {isAuthenticated && onSaveToCloud ? ( + + ) : ( +
+ Local +
+ )} +
+ )} + {canEdit && !isLocalProject(project) && ( +
+ {onViewClick && ( + + )} + +
+ )} +
- {isLocalProject(project) && ( -
- {new Date(project.updated_at).toLocaleDateString()} + {/* Info row below the card */} +
+ {/* Avatar */} + {showOwner && owner ? ( + e.stopPropagation()} + className="shrink-0" + > + {owner.image ? ( + {owner.name} + ) : ( +
+ {owner.name?.[0]?.toUpperCase() || '?'} +
+ )} + + ) : null} + + {/* Name + stats */} +
+

{project.name}

+
+ {showOwner && owner && ( + <> + e.stopPropagation()} + className="hover:text-foreground transition-colors truncate" + > + {owner.username || owner.name} + + {!isLocalProject(project) && ·} + + )} + {!isLocalProject(project) && ( + <> +
+ + {project.views} +
+ · + + + )} + {isLocalProject(project) && ( + {new Date(project.updated_at).toLocaleDateString()} + )} +
- )} +
-
- ))} + ) + })}
{/* Settings Dialog */} diff --git a/apps/editor/features/community/components/public-profile-page.tsx b/apps/editor/features/community/components/public-profile-page.tsx new file mode 100644 index 00000000..dd3c474a --- /dev/null +++ b/apps/editor/features/community/components/public-profile-page.tsx @@ -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 ( + + + + ) +} + +function XIcon({ className }: { className?: string }) { + return ( + + + + ) +} + +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 ( +
+ {/* Header — same layout as the Hub */} +
+
+
+ + Pascal + Hub + + + + + + +
+
+
+ +
+ {/* Profile Header */} +
+ {profile.image ? ( + {profile.name} + ) : ( +
+ {profile.name[0]?.toUpperCase() || '?'} +
+ )} +
+

{profile.name}

+

@{profile.username}

+ {(profile.githubUrl || profile.xUrl) && ( +
+ {profile.githubUrl && ( + + + + )} + {profile.xUrl && ( + + + + )} +
+ )} +
+
+ + {/* Projects */} +
+

Public Projects

+ {projects.length > 0 ? ( + router.push(`/viewer/${id}`)} + showOwner={false} + /> + ) : ( +
+ No public projects yet +
+ )} +
+
+ + +
+ ) +} diff --git a/apps/editor/features/community/components/settings-page.tsx b/apps/editor/features/community/components/settings-page.tsx new file mode 100644 index 00000000..e746ed51 --- /dev/null +++ b/apps/editor/features/community/components/settings-page.tsx @@ -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 ( +
+
+
+
+ + + Back + +

Settings

+
+
+
+ +
+ {/* Profile Section */} +
+

Profile

+
+
+ {user.image ? ( + {user.name + ) : ( +
+ {user.name?.[0]?.toUpperCase() || user.email?.[0]?.toUpperCase() || 'U'} +
+ )} +
+ {user.name &&
{user.name}
} + {user.email && ( +
{user.email}
+ )} +
+
+ +
+
+ +

+ Your public display name on the community hub. +

+ { + 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_-]+" + /> +

+ 3-30 characters. Letters, numbers, hyphens, and underscores only. +

+
+ + {usernameMessage && ( +
+ {usernameMessage.text} +
+ )} + + +
+
+
+ + {/* Social Links Section */} +
+

Social Links

+
+
+
+ + { + 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} + /> +
+ +
+ + { + 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} + /> +
+ + {socialMessage && ( +
+ {socialMessage.text} +
+ )} + + +
+
+
+
+
+ ) +} diff --git a/apps/editor/features/community/components/sign-in-dialog.tsx b/apps/editor/features/community/components/sign-in-dialog.tsx index efb6bc05..90616c4b 100644 --- a/apps/editor/features/community/components/sign-in-dialog.tsx +++ b/apps/editor/features/community/components/sign-in-dialog.tsx @@ -10,22 +10,59 @@ interface SignInDialogProps { onOpenChange: (open: boolean) => void } +function GoogleIcon({ className }: { className?: string }) { + return ( + + + + + + + ) +} + /** - * SignInDialog - Magic link authentication dialog + * SignInDialog - Authentication dialog with Google OAuth and magic link */ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) { const [email, setEmail] = useState('') const [isLoading, setIsLoading] = useState(false) + const [isGoogleLoading, setIsGoogleLoading] = useState(false) const [error, setError] = useState(null) const [success, setSuccess] = useState(false) + const handleGoogleSignIn = async () => { + setError(null) + setIsGoogleLoading(true) + try { + await authClient.signIn.social({ + provider: 'google', + callbackURL: window.location.origin, + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to sign in with Google') + setIsGoogleLoading(false) + } + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError(null) setIsLoading(true) try { - // Use better-auth's magic link sign in const result = await authClient.signIn.magicLink({ email, callbackURL: window.location.origin, @@ -45,9 +82,8 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) { } const handleClose = () => { - if (!isLoading) { + if (!isLoading && !isGoogleLoading) { onOpenChange(false) - // Reset state after a short delay to avoid flash setTimeout(() => { setEmail('') setError(null) @@ -56,6 +92,8 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) { } } + const anyLoading = isLoading || isGoogleLoading + return ( @@ -63,7 +101,7 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) { Sign in to Pascal
) : ( -
-
- - setEmail(e.target.value)} - /> -
- - {error && ( -
- {error} -
- )} - +
+ {/* Google Sign-In */} + {/* Divider */} +
+
+ +
+
+ or +
+
+ + {/* Magic Link Form */} + +
+ + setEmail(e.target.value)} + /> +
+ + {error && ( +
+ {error} +
+ )} + + + +

- We'll send you a magic link to sign in without a password. + Sign in with Google or receive a magic link via email.

- +
)} diff --git a/apps/editor/features/community/components/username-gate.tsx b/apps/editor/features/community/components/username-gate.tsx new file mode 100644 index 00000000..f5b58875 --- /dev/null +++ b/apps/editor/features/community/components/username-gate.tsx @@ -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} + setNeedsUsername(false)} + /> + + ) +} diff --git a/apps/editor/features/community/components/username-onboarding-dialog.tsx b/apps/editor/features/community/components/username-onboarding-dialog.tsx new file mode 100644 index 00000000..90e3888a --- /dev/null +++ b/apps/editor/features/community/components/username-onboarding-dialog.tsx @@ -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(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 ( + {}}> + + + Choose your username + + +

+ Pick a public username for the community hub. This will be visible on projects you share. +

+ +
+
+
+ + @ + + { + 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} + /> +
+ + {/* Status indicators */} + {username.trim() && !validationError && ( +
+ {availability === 'checking' && ( + Checking availability... + )} + {availability === 'available' && ( + Username is available + )} + {availability === 'taken' && ( + Username is already taken + )} +
+ )} + {validationError && ( +

{validationError}

+ )} + {!username.trim() && ( +

+ 3-30 characters. Letters, numbers, hyphens, and underscores only. +

+ )} +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+ ) +} diff --git a/apps/editor/features/community/lib/auth/actions.ts b/apps/editor/features/community/lib/auth/actions.ts new file mode 100644 index 00000000..12a5c70d --- /dev/null +++ b/apps/editor/features/community/lib/auth/actions.ts @@ -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 { + 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 } } +} diff --git a/apps/editor/features/community/lib/projects/actions.ts b/apps/editor/features/community/lib/projects/actions.ts index e2f9d736..504063ce 100644 --- a/apps/editor/features/community/lib/projects/actions.ts +++ b/apps/editor/features/community/lib/projects/actions.ts @@ -397,7 +397,8 @@ export async function getPublicProjects(): Promise> { .from('projects') .select(` *, - address:projects_addresses(*) + address:projects_addresses(*), + owner:auth_users!owner_id(id, name, username, image) `) .eq('is_private', false) .order('views', { ascending: false }) @@ -424,6 +425,37 @@ export async function getPublicProjects(): Promise> { } } +/** + * Fetch public projects for a specific user (by user ID) + */ +export async function getPublicProjectsByUserId(userId: string): Promise> { + try { + const supabase = await createServerSupabaseClient() + + const { data, error } = await supabase + .from('projects') + .select(` + *, + address:projects_addresses(*) + `) + .eq('owner_id', userId) + .eq('is_private', false) + .order('created_at', { ascending: false }) + + if (error) { + return { success: false, error: error.message, data: [] } + } + + return { success: true, data: data as Project[] } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch projects', + data: [], + } + } +} + /** * Get a project model for viewing * Allows viewing if: project is public OR user owns the project @@ -440,7 +472,8 @@ export async function getProjectModelPublic(projectId: string): Promise< .from('projects') .select(` *, - address:projects_addresses(*) + address:projects_addresses(*), + owner:auth_users!owner_id(id, name, username, image) `) .eq('id', projectId) .single() diff --git a/apps/editor/features/community/lib/projects/types.ts b/apps/editor/features/community/lib/projects/types.ts index db7e8862..9ddd658e 100644 --- a/apps/editor/features/community/lib/projects/types.ts +++ b/apps/editor/features/community/lib/projects/types.ts @@ -87,6 +87,13 @@ export type Database = { } } +export type ProjectOwner = { + id: string + name: string + username: string | null + image: string | null +} + export type Project = { id: string name: string @@ -111,6 +118,7 @@ export type Project = { latitude?: string longitude?: string } | null + owner?: ProjectOwner | null } export type CreateProjectParams = { diff --git a/apps/editor/lib/auth.ts b/apps/editor/lib/auth.ts index c71a7363..794baa8e 100644 --- a/apps/editor/lib/auth.ts +++ b/apps/editor/lib/auth.ts @@ -12,6 +12,8 @@ export const auth = createAuth({ appName: 'Pascal Editor', baseURL: BASE_URL, secret: process.env.BETTER_AUTH_SECRET!, + googleClientId: process.env.GOOGLE_CLIENT_ID, + googleClientSecret: process.env.GOOGLE_CLIENT_SECRET, sendMagicLink: async ({ email, url }) => { if (!resend) { console.log(`[DEV] Magic link for ${email}: ${url}`) diff --git a/package.json b/package.json index 61da94dc..f9beffb3 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,10 @@ "check:fix": "biome check --write", "check-types": "turbo run check-types", "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:stop": "supabase stop", "db:reset": "supabase db reset", diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index 0929c1c0..5e127e18 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -16,6 +16,10 @@ export interface AuthConfig { appName: string baseURL: string secret: string + /** Google OAuth client ID */ + googleClientId?: string + /** Google OAuth client secret */ + googleClientSecret?: string /** Callback to send magic link emails */ sendMagicLink?: (params: SendMagicLinkParams) => Promise /** Additional plugins to add (e.g., nextCookies for web) */ @@ -57,6 +61,22 @@ export function createAuth(config: AuthConfig): ReturnType { }, }, }, + // 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: [ ...(config.additionalPlugins ?? []), // Magic link authentication diff --git a/packages/db/src/schema/auth/users.ts b/packages/db/src/schema/auth/users.ts index e01799f9..ac51834b 100644 --- a/packages/db/src/schema/auth/users.ts +++ b/packages/db/src/schema/auth/users.ts @@ -13,13 +13,22 @@ export const users = pgTable( emailVerified: t.boolean('email_verified').notNull().default(false), name: t.text('name').notNull(), 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'), banned: t.boolean('banned').notNull().default(false), banReason: t.text('ban_reason'), banExpires: t.timestamp('ban_expires', { withTimezone: true }), ...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() export type User = typeof users.$inferSelect diff --git a/supabase/migrations/20260219051019_add-username-field.sql b/supabase/migrations/20260219051019_add-username-field.sql new file mode 100644 index 00000000..09595e7f --- /dev/null +++ b/supabase/migrations/20260219051019_add-username-field.sql @@ -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")); \ No newline at end of file diff --git a/supabase/migrations/20260219052405_add-social-profile-fields.sql b/supabase/migrations/20260219052405_add-social-profile-fields.sql new file mode 100644 index 00000000..4dc90815 --- /dev/null +++ b/supabase/migrations/20260219052405_add-social-profile-fields.sql @@ -0,0 +1,2 @@ +ALTER TABLE "auth_users" ADD COLUMN "github_url" text;--> statement-breakpoint +ALTER TABLE "auth_users" ADD COLUMN "x_url" text; \ No newline at end of file diff --git a/supabase/migrations/meta/20260219051010_snapshot.json b/supabase/migrations/meta/20260219051010_snapshot.json new file mode 100644 index 00000000..c6913f9e --- /dev/null +++ b/supabase/migrations/meta/20260219051010_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/supabase/migrations/meta/20260219051019_snapshot.json b/supabase/migrations/meta/20260219051019_snapshot.json new file mode 100644 index 00000000..ef49cca4 --- /dev/null +++ b/supabase/migrations/meta/20260219051019_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/supabase/migrations/meta/20260219052405_snapshot.json b/supabase/migrations/meta/20260219052405_snapshot.json new file mode 100644 index 00000000..ec147113 --- /dev/null +++ b/supabase/migrations/meta/20260219052405_snapshot.json @@ -0,0 +1,1000 @@ +{ + "id": "57bff2fc-7d82-473b-afdb-2dd34f7a2696", + "prevId": "a414a135-fe6d-4192-9d7c-52a09abead20", + "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 + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "x_url": { + "name": "x_url", + "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": {} + } +} \ No newline at end of file diff --git a/supabase/migrations/meta/_journal.json b/supabase/migrations/meta/_journal.json new file mode 100644 index 00000000..a978044f --- /dev/null +++ b/supabase/migrations/meta/_journal.json @@ -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 + } + ] +} \ No newline at end of file