feat: add Google OAuth, user profiles, community hub improvements
- Add Google OAuth sign-in alongside magic link authentication - Username onboarding: require username selection after first sign-in - Public profile pages at /u/[username] with social links (GitHub, X) - Settings page for managing username and social links - Viewer header: unified floating card with project name, @username, breadcrumb - Guest CTA on viewer page prompting sign-in - Show owner avatar + username on community project cards - Redesigned project cards (larger preview, avatar + stats row below) - Consistent navbar across hub and profile pages with GitHub link - Footer with links to GitHub repo and npm packages - Borderless design with natural shadows throughout - Apple corner smoothing (squircle) progressive enhancement - cursor:pointer globally on buttons and links - DB migrations: username, github_url, x_url columns on auth_users - Root-level db:generate/migrate/push/studio scripts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
0fd8656090
commit
62dde6ef5a
@@ -119,6 +119,21 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
a {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Apple-style smooth corners (squircle) — progressive enhancement */
|
||||||
|
.rounded-smooth {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
corner-shape: squircle;
|
||||||
|
}
|
||||||
|
.rounded-smooth-xl {
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
corner-shape: squircle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import localFont from 'next/font/local'
|
import localFont from 'next/font/local'
|
||||||
|
import { UsernameGate } from '@/features/community/components/username-gate'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
const geistSans = localFont({
|
const geistSans = localFont({
|
||||||
@@ -23,7 +24,9 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>{children}</body>
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
|
<UsernameGate>{children}</UsernameGate>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { getSession } from '@/features/community/lib/auth/server'
|
||||||
|
import { getUserProfile } from '@/features/community/lib/auth/actions'
|
||||||
|
import { SettingsPage } from '@/features/community/components/settings-page'
|
||||||
|
|
||||||
|
export default async function Settings() {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await getUserProfile()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsPage
|
||||||
|
user={session.user}
|
||||||
|
currentUsername={profile?.username ?? null}
|
||||||
|
currentGithubUrl={profile?.githubUrl ?? null}
|
||||||
|
currentXUrl={profile?.xUrl ?? null}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
import { notFound } from 'next/navigation'
|
||||||
|
import { getPublicProfile } from '@/features/community/lib/auth/actions'
|
||||||
|
import { getPublicProjectsByUserId } from '@/features/community/lib/projects/actions'
|
||||||
|
import { PublicProfilePage } from '@/features/community/components/public-profile-page'
|
||||||
|
|
||||||
|
export default async function ProfilePage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ username: string }>
|
||||||
|
}) {
|
||||||
|
const { username } = await params
|
||||||
|
const profileResult = await getPublicProfile(username)
|
||||||
|
|
||||||
|
if (!profileResult.success || !profileResult.data) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectsResult = await getPublicProjectsByUserId(profileResult.data.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PublicProfilePage
|
||||||
|
profile={profileResult.data}
|
||||||
|
projects={projectsResult.data ?? []}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ import { ViewerCameraControls } from './viewer-camera-controls'
|
|||||||
import { ViewerOverlay } from './viewer-overlay'
|
import { ViewerOverlay } from './viewer-overlay'
|
||||||
import { ViewerZoneSystem } from './viewer-zone-system'
|
import { ViewerZoneSystem } from './viewer-zone-system'
|
||||||
import { ThumbnailGenerator } from './thumbnail-generator'
|
import { ThumbnailGenerator } from './thumbnail-generator'
|
||||||
|
import { ViewerGuestCTA } from './viewer-guest-cta'
|
||||||
import { getProjectModelPublic, incrementProjectViews } from '@/features/community/lib/projects/actions'
|
import { getProjectModelPublic, incrementProjectViews } from '@/features/community/lib/projects/actions'
|
||||||
|
import type { ProjectOwner } from '@/features/community/lib/projects/types'
|
||||||
|
|
||||||
export default function ViewerPage() {
|
export default function ViewerPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -16,6 +18,8 @@ export default function ViewerPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [projectId, setProjectId] = useState<string | null>(null)
|
const [projectId, setProjectId] = useState<string | null>(null)
|
||||||
|
const [projectName, setProjectName] = useState<string | null>(null)
|
||||||
|
const [owner, setOwner] = useState<ProjectOwner | null>(null)
|
||||||
const setScene = useScene((state) => state.setScene)
|
const setScene = useScene((state) => state.setScene)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -32,6 +36,7 @@ export default function ViewerPage() {
|
|||||||
setScene(data.nodes, data.rootNodeIds)
|
setScene(data.nodes, data.rootNodeIds)
|
||||||
initSpatialGridSync()
|
initSpatialGridSync()
|
||||||
}
|
}
|
||||||
|
setProjectName('Demo')
|
||||||
} else {
|
} else {
|
||||||
// Load from database (public project)
|
// Load from database (public project)
|
||||||
const result = await getProjectModelPublic(id)
|
const result = await getProjectModelPublic(id)
|
||||||
@@ -39,6 +44,8 @@ export default function ViewerPage() {
|
|||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
const { project, model } = result.data
|
const { project, model } = result.data
|
||||||
setProjectId(project.id)
|
setProjectId(project.id)
|
||||||
|
setProjectName(project.name)
|
||||||
|
setOwner((project as any).owner ?? null)
|
||||||
|
|
||||||
if (model?.scene_graph) {
|
if (model?.scene_graph) {
|
||||||
const { nodes, rootNodeIds } = model.scene_graph
|
const { nodes, rootNodeIds } = model.scene_graph
|
||||||
@@ -81,13 +88,11 @@ export default function ViewerPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-screen w-full">
|
<div className="relative h-screen w-full">
|
||||||
<ViewerOverlay />
|
<ViewerOverlay projectName={projectName} owner={owner} />
|
||||||
|
<ViewerGuestCTA />
|
||||||
<Viewer>
|
<Viewer>
|
||||||
{/* Custom Camera Controls */}
|
|
||||||
<ViewerCameraControls />
|
<ViewerCameraControls />
|
||||||
{/* Custom Zone System */}
|
|
||||||
<ViewerZoneSystem />
|
<ViewerZoneSystem />
|
||||||
{/* Thumbnail Generator */}
|
|
||||||
<ThumbnailGenerator projectId={projectId || undefined} />
|
<ThumbnailGenerator projectId={projectId || undefined} />
|
||||||
</Viewer>
|
</Viewer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useAuth } from '@/features/community/lib/auth/hooks'
|
||||||
|
import { SignInDialog } from '@/features/community/components/sign-in-dialog'
|
||||||
|
|
||||||
|
export function ViewerGuestCTA() {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth()
|
||||||
|
const [showSignIn, setShowSignIn] = useState(false)
|
||||||
|
|
||||||
|
if (isLoading || isAuthenticated) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20">
|
||||||
|
<div className="bg-white/90 backdrop-blur-sm rounded-xl rounded-smooth-xl px-6 py-3 shadow-[0_4px_16px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.03)] flex items-center gap-4">
|
||||||
|
<p className="text-sm text-neutral-700">Want to create your own 3D project?</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSignIn(true)}
|
||||||
|
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SignInDialog open={showSignIn} onOpenChange={setShowSignIn} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { type AnyNode, type AnyNodeId, type BuildingNode, type LevelNode, type ZoneNode, useScene } from '@pascal-app/core'
|
import { type AnyNode, type AnyNodeId, type BuildingNode, type LevelNode, type ZoneNode, useScene } from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Box, ChevronRight, Diamond, Eye, EyeOff, Image, Layers, Layers2 } from 'lucide-react'
|
import Link from 'next/link'
|
||||||
|
import { ArrowLeft, Box, ChevronRight, Diamond, Eye, EyeOff, Image, Layers, Layers2 } from 'lucide-react'
|
||||||
|
import type { ProjectOwner } from '@/features/community/lib/projects/types'
|
||||||
|
|
||||||
const getNodeName = (node: AnyNode): string => {
|
const getNodeName = (node: AnyNode): string => {
|
||||||
if ('name' in node && node.name) return node.name
|
if ('name' in node && node.name) return node.name
|
||||||
@@ -14,7 +16,12 @@ const getNodeName = (node: AnyNode): string => {
|
|||||||
return node.type
|
return node.type
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ViewerOverlay = () => {
|
interface ViewerOverlayProps {
|
||||||
|
projectName?: string | null
|
||||||
|
owner?: ProjectOwner | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ViewerOverlay = ({ projectName, owner }: ViewerOverlayProps) => {
|
||||||
const selection = useViewer((s) => s.selection)
|
const selection = useViewer((s) => s.selection)
|
||||||
const nodes = useScene((s) => s.nodes)
|
const nodes = useScene((s) => s.nodes)
|
||||||
const showScans = useViewer((s) => s.showScans)
|
const showScans = useViewer((s) => s.showScans)
|
||||||
@@ -59,9 +66,36 @@ export const ViewerOverlay = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Unified top-left card */}
|
||||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-3">
|
<div className="absolute top-4 left-4 z-10 flex flex-col gap-3">
|
||||||
{/* Breadcrumb */}
|
<div className="bg-white/80 backdrop-blur-sm rounded-lg rounded-smooth shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] overflow-hidden">
|
||||||
<div className="flex items-center gap-1 text-sm">
|
{/* Project info + back */}
|
||||||
|
<div className="flex items-center gap-3 px-3 py-2">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-neutral-100 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 text-neutral-500" />
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-neutral-800 truncate">
|
||||||
|
{projectName || 'Untitled'}
|
||||||
|
</div>
|
||||||
|
{owner?.username && (
|
||||||
|
<Link
|
||||||
|
href={`/u/${owner.username}`}
|
||||||
|
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
@{owner.username}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Breadcrumb — only shown when navigated into a building */}
|
||||||
|
{building && (
|
||||||
|
<div className="border-t border-neutral-100 px-3 py-1.5">
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBreadcrumbClick('root')}
|
onClick={() => handleBreadcrumbClick('root')}
|
||||||
className="text-neutral-500 hover:text-neutral-800 transition-colors"
|
className="text-neutral-500 hover:text-neutral-800 transition-colors"
|
||||||
@@ -71,10 +105,10 @@ export const ViewerOverlay = () => {
|
|||||||
|
|
||||||
{building && (
|
{building && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBreadcrumbClick('building')}
|
onClick={() => handleBreadcrumbClick('building')}
|
||||||
className={`transition-colors ${level ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
|
className={`transition-colors truncate ${level ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
|
||||||
>
|
>
|
||||||
{building.name || 'Building'}
|
{building.name || 'Building'}
|
||||||
</button>
|
</button>
|
||||||
@@ -83,10 +117,10 @@ export const ViewerOverlay = () => {
|
|||||||
|
|
||||||
{level && (
|
{level && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBreadcrumbClick('level')}
|
onClick={() => handleBreadcrumbClick('level')}
|
||||||
className={`transition-colors ${zone ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
|
className={`transition-colors truncate ${zone ? 'text-neutral-500 hover:text-neutral-800' : 'text-neutral-800 font-medium'}`}
|
||||||
>
|
>
|
||||||
{level.name || `Level ${level.level}`}
|
{level.name || `Level ${level.level}`}
|
||||||
</button>
|
</button>
|
||||||
@@ -95,8 +129,8 @@ export const ViewerOverlay = () => {
|
|||||||
|
|
||||||
{zone && (
|
{zone && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
||||||
<span className={`transition-colors ${selectedNode ? 'text-neutral-500' : 'text-neutral-800 font-medium'}`}>
|
<span className={`transition-colors truncate ${selectedNode ? 'text-neutral-500' : 'text-neutral-800 font-medium'}`}>
|
||||||
{zone.name}
|
{zone.name}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -104,15 +138,18 @@ export const ViewerOverlay = () => {
|
|||||||
|
|
||||||
{selectedNode && zone && (
|
{selectedNode && zone && (
|
||||||
<>
|
<>
|
||||||
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
<ChevronRight className="w-3 h-3 text-neutral-400" />
|
||||||
<span className="text-neutral-800 font-medium">{getNodeName(selectedNode)}</span>
|
<span className="text-neutral-800 font-medium truncate">{getNodeName(selectedNode)}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Level List (only when building is selected) */}
|
{/* Level List (only when building is selected) */}
|
||||||
{building && levels.length > 0 && (
|
{building && levels.length > 0 && (
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200 w-40">
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)] w-40">
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Levels</span>
|
<span className="text-xs text-neutral-500 px-2 pb-1">Levels</span>
|
||||||
{levels.map((lvl) => (
|
{levels.map((lvl) => (
|
||||||
<button
|
<button
|
||||||
@@ -134,7 +171,7 @@ export const ViewerOverlay = () => {
|
|||||||
{/* Controls Panel - Top Right */}
|
{/* Controls Panel - Top Right */}
|
||||||
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2">
|
<div className="absolute top-4 right-4 z-10 flex flex-col gap-2">
|
||||||
{/* Visibility Controls */}
|
{/* Visibility Controls */}
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200">
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Visibility</span>
|
<span className="text-xs text-neutral-500 px-2 pb-1">Visibility</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => useViewer.getState().setShowScans(!showScans)}
|
onClick={() => useViewer.getState().setShowScans(!showScans)}
|
||||||
@@ -157,7 +194,7 @@ export const ViewerOverlay = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Camera Mode */}
|
{/* Camera Mode */}
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200">
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Camera</span>
|
<span className="text-xs text-neutral-500 px-2 pb-1">Camera</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
|
onClick={() => useViewer.getState().setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')}
|
||||||
@@ -173,7 +210,7 @@ export const ViewerOverlay = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Level Mode */}
|
{/* Level Mode */}
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200">
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Level Mode</span>
|
<span className="text-xs text-neutral-500 px-2 pb-1">Level Mode</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => useViewer.getState().setLevelMode('stacked')}
|
onClick={() => useViewer.getState().setLevelMode('stacked')}
|
||||||
@@ -205,7 +242,7 @@ export const ViewerOverlay = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wall Mode */}
|
{/* Wall Mode */}
|
||||||
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-sm border border-neutral-200">
|
<div className="flex flex-col gap-1 bg-white/80 backdrop-blur-sm rounded-lg p-2 shadow-[0_1px_4px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.03)]">
|
||||||
<span className="text-xs text-neutral-500 px-2 pb-1">Wall Mode</span>
|
<span className="text-xs text-neutral-500 px-2 pb-1">Wall Mode</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => useViewer.getState().setWallMode('cutaway')}
|
onClick={() => useViewer.getState().setWallMode('cutaway')}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createLocalProject, getLocalProjects } from '../lib/local-storage/proje
|
|||||||
import { getPublicProjects, getUserProjects } from '../lib/projects/actions'
|
import { getPublicProjects, getUserProjects } from '../lib/projects/actions'
|
||||||
import type { Project } from '../lib/projects/types'
|
import type { Project } from '../lib/projects/types'
|
||||||
import { CreateProjectButton } from './create-project-button'
|
import { CreateProjectButton } from './create-project-button'
|
||||||
|
import { HubFooter } from './hub-footer'
|
||||||
import { NewProjectDialog } from './new-project-dialog'
|
import { NewProjectDialog } from './new-project-dialog'
|
||||||
import { ProfileDropdown } from './profile-dropdown'
|
import { ProfileDropdown } from './profile-dropdown'
|
||||||
import { ProjectGrid } from './project-grid'
|
import { ProjectGrid } from './project-grid'
|
||||||
@@ -119,6 +120,17 @@ export default function CommunityHub() {
|
|||||||
/>
|
/>
|
||||||
<h1 className="text-2xl font-bold">Hub</h1>
|
<h1 className="text-2xl font-bold">Hub</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href="https://github.com/pascalorg/editor"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
{!isAuthenticated ? (
|
{!isAuthenticated ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSignInDialogOpen(true)}
|
onClick={() => setIsSignInDialogOpen(true)}
|
||||||
@@ -131,6 +143,7 @@ export default function CommunityHub() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="container mx-auto px-6 py-8 space-y-12">
|
<main className="container mx-auto px-6 py-8 space-y-12">
|
||||||
@@ -209,6 +222,8 @@ export default function CommunityHub() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<HubFooter />
|
||||||
|
|
||||||
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
|
<SignInDialog open={isSignInDialogOpen} onOpenChange={setIsSignInDialogOpen} />
|
||||||
<NewProjectDialog
|
<NewProjectDialog
|
||||||
open={isNewProjectDialogOpen}
|
open={isNewProjectDialogOpen}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function CreateProjectButton({ onCreateProject }: CreateProjectButtonProp
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onCreateProject}
|
onClick={onCreateProject}
|
||||||
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
|
className="flex items-center gap-2 rounded-full bg-primary px-5 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
<span>Create Project</span>
|
<span>Create Project</span>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
function GitHubIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NpmIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 256 256">
|
||||||
|
<rect width="256" height="256" rx="0" fill="#C12127" />
|
||||||
|
<polygon points="48,48 208,48 208,208 176,208 176,80 128,80 128,208 48,208" fill="#FFFFFF" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HubFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-border mt-16">
|
||||||
|
<div className="container mx-auto px-6 py-8">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Editor by{' '}
|
||||||
|
<a
|
||||||
|
href="https://pascal.app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Pascal
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href="https://github.com/pascalorg/editor"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<GitHubIcon className="h-4 w-4" />
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.npmjs.com/package/@pascal-app/viewer"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<NpmIcon className="h-4 w-4" />
|
||||||
|
Viewer
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.npmjs.com/package/@pascal-app/core"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<NpmIcon className="h-4 w-4" />
|
||||||
|
Core
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { useAuth } from '../lib/auth/hooks'
|
import { useAuth } from '../lib/auth/hooks'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/primitives/dropdown-menu'
|
} from '@/components/ui/primitives/dropdown-menu'
|
||||||
|
|
||||||
@@ -18,15 +21,14 @@ function getInitials(name: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ProfileDropdown - User profile menu with sign out
|
* ProfileDropdown - User profile menu with avatar, settings, and sign out
|
||||||
*/
|
*/
|
||||||
export function ProfileDropdown() {
|
export function ProfileDropdown() {
|
||||||
const { user, signOut } = useAuth()
|
const { user, signOut } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
await signOut()
|
await signOut()
|
||||||
// TODO: Show sign-in dialog or redirect
|
|
||||||
console.log('Signed out')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initials = user?.name ? getInitials(user.name) : user?.email?.[0]?.toUpperCase() || 'U'
|
const initials = user?.name ? getInitials(user.name) : user?.email?.[0]?.toUpperCase() || 'U'
|
||||||
@@ -35,20 +37,49 @@ export function ProfileDropdown() {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-border bg-background/95 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none"
|
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg border border-border bg-background/95 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{initials}
|
{user?.image ? (
|
||||||
|
<Image
|
||||||
|
src={user.image}
|
||||||
|
alt={user.name || 'Profile'}
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initials
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
{user?.name && (
|
<div className="flex items-center gap-3 px-2 py-2">
|
||||||
<div className="px-2 py-1.5 text-sm">
|
{user?.image ? (
|
||||||
<div className="font-medium">{user.name}</div>
|
<Image
|
||||||
{user.email && <div className="text-muted-foreground text-xs">{user.email}</div>}
|
src={user.image}
|
||||||
|
alt={user.name || 'Profile'}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="h-8 w-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted font-medium text-xs">
|
||||||
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{user?.name && <DropdownMenuItem className="h-px bg-border" />}
|
<div className="min-w-0 flex-1">
|
||||||
|
{user?.name && <div className="truncate font-medium text-sm">{user.name}</div>}
|
||||||
|
{user?.email && (
|
||||||
|
<div className="truncate text-muted-foreground text-xs">{user.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="cursor-pointer" onClick={() => router.push('/settings')}>
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="cursor-pointer" variant="destructive" onClick={handleSignOut}>
|
<DropdownMenuItem className="cursor-pointer" variant="destructive" onClick={handleSignOut}>
|
||||||
Sign out
|
Sign out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Eye, Heart, Settings } from 'lucide-react'
|
import { Eye, Heart, Settings } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Project } from '../lib/projects/types'
|
import type { Project } from '../lib/projects/types'
|
||||||
import type { LocalProject } from '../lib/local-storage/project-store'
|
import type { LocalProject } from '../lib/local-storage/project-store'
|
||||||
@@ -116,15 +117,18 @@ export function ProjectGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => {
|
||||||
|
const owner = !isLocalProject(project) ? project.owner : null
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={() => onProjectClick(project.id)}
|
onClick={() => onProjectClick(project.id)}
|
||||||
className="group relative overflow-hidden rounded-lg border border-border bg-card hover:border-primary transition-all text-left cursor-pointer"
|
className="group text-left cursor-pointer"
|
||||||
>
|
>
|
||||||
{/* Thumbnail */}
|
{/* Thumbnail card */}
|
||||||
<div className="aspect-video bg-muted relative">
|
<div className="relative aspect-[4/3] rounded-xl rounded-smooth-xl bg-neutral-50 overflow-hidden shadow-[0_1px_3px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.04)] transition-shadow group-hover:shadow-[0_4px_12px_rgba(0,0,0,0.08),0_0_0_1px_rgba(0,0,0,0.04)]">
|
||||||
{!isLocalProject(project) && project.thumbnail_url ? (
|
{!isLocalProject(project) && project.thumbnail_url ? (
|
||||||
<img
|
<img
|
||||||
src={project.thumbnail_url}
|
src={project.thumbnail_url}
|
||||||
@@ -137,27 +141,27 @@ export function ProjectGrid({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isLocalProject(project) && (
|
{isLocalProject(project) && (
|
||||||
<div className="absolute top-2 right-2">
|
<div className="absolute top-3 right-3">
|
||||||
{isAuthenticated && onSaveToCloud ? (
|
{isAuthenticated && onSaveToCloud ? (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onSaveToCloud(project)
|
onSaveToCloud(project)
|
||||||
}}
|
}}
|
||||||
className="bg-blue-500 hover:bg-blue-600 text-white text-xs px-2 py-1 rounded transition-colors"
|
className="bg-blue-500 hover:bg-blue-600 text-white text-xs px-2.5 py-1 rounded-md transition-colors"
|
||||||
title="Save to cloud"
|
title="Save to cloud"
|
||||||
>
|
>
|
||||||
Save to cloud
|
Save to cloud
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-blue-500 text-white text-xs px-2 py-1 rounded">
|
<div className="bg-blue-500 text-white text-xs px-2.5 py-1 rounded-md">
|
||||||
Local
|
Local
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canEdit && !isLocalProject(project) && (
|
{canEdit && !isLocalProject(project) && (
|
||||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute top-3 right-3 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{onViewClick && (
|
{onViewClick && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleViewClick(e, project.id)}
|
onClick={(e) => handleViewClick(e, project.id)}
|
||||||
@@ -180,23 +184,59 @@ export function ProjectGrid({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info row below the card */}
|
||||||
<div className="p-4">
|
<div className="flex items-center gap-3 mt-3">
|
||||||
<h3 className="font-medium text-left line-clamp-2 mb-2">{project.name}</h3>
|
{/* Avatar */}
|
||||||
|
{showOwner && owner ? (
|
||||||
|
<Link
|
||||||
|
href={owner.username ? `/u/${owner.username}` : '#'}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{owner.image ? (
|
||||||
|
<img
|
||||||
|
src={owner.image}
|
||||||
|
alt={owner.name}
|
||||||
|
className="w-9 h-9 rounded-full object-cover shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_1px_3px_rgba(0,0,0,0.1)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-9 h-9 rounded-full bg-neutral-100 flex items-center justify-center text-sm font-medium shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
||||||
|
{owner.name?.[0]?.toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Name + stats */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className="font-medium text-sm truncate">{project.name}</h3>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||||
|
{showOwner && owner && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={owner.username ? `/u/${owner.username}` : '#'}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="hover:text-foreground transition-colors truncate"
|
||||||
|
>
|
||||||
|
{owner.username || owner.name}
|
||||||
|
</Link>
|
||||||
|
{!isLocalProject(project) && <span className="shrink-0">·</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!isLocalProject(project) && (
|
{!isLocalProject(project) && (
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
<>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-3.5 h-3.5" />
|
||||||
<span>{project.views}</span>
|
<span>{project.views}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="shrink-0">·</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleLikeClick(e, project.id)}
|
onClick={(e) => handleLikeClick(e, project.id)}
|
||||||
className="flex items-center gap-1 hover:text-red-500 transition-colors"
|
className="flex items-center gap-0.5 shrink-0 hover:text-red-500 transition-colors"
|
||||||
disabled={!isAuthenticated}
|
disabled={!isAuthenticated}
|
||||||
>
|
>
|
||||||
<Heart
|
<Heart
|
||||||
className={`w-4 h-4 ${
|
className={`w-3.5 h-3.5 ${
|
||||||
userLikes[project.id]
|
userLikes[project.id]
|
||||||
? 'fill-red-500 text-red-500'
|
? 'fill-red-500 text-red-500'
|
||||||
: ''
|
: ''
|
||||||
@@ -204,17 +244,17 @@ export function ProjectGrid({
|
|||||||
/>
|
/>
|
||||||
<span>{likeCounts[project.id] ?? project.likes}</span>
|
<span>{likeCounts[project.id] ?? project.likes}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLocalProject(project) && (
|
{isLocalProject(project) && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<span>{new Date(project.updated_at).toLocaleDateString()}</span>
|
||||||
{new Date(project.updated_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings Dialog */}
|
{/* Settings Dialog */}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { ProjectGrid } from './project-grid'
|
||||||
|
import { HubFooter } from './hub-footer'
|
||||||
|
import type { Project } from '../lib/projects/types'
|
||||||
|
|
||||||
|
function GitHubIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function XIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PublicProfilePageProps {
|
||||||
|
profile: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
image: string | null
|
||||||
|
username: string
|
||||||
|
githubUrl: string | null
|
||||||
|
xUrl: string | null
|
||||||
|
}
|
||||||
|
projects: Project[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PublicProfilePage({ profile, projects }: PublicProfilePageProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* Header — same layout as the Hub */}
|
||||||
|
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
|
||||||
|
<div className="container mx-auto px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<Image
|
||||||
|
src="/pascal-logo-shape.svg"
|
||||||
|
alt="Pascal"
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="h-5 w-5"
|
||||||
|
/>
|
||||||
|
<span className="text-2xl font-bold">Hub</span>
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href="https://github.com/pascalorg/editor"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto max-w-4xl px-6 py-8 space-y-8">
|
||||||
|
{/* Profile Header */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
{profile.image ? (
|
||||||
|
<Image
|
||||||
|
src={profile.image}
|
||||||
|
alt={profile.name}
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className="h-20 w-20 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted font-bold text-2xl">
|
||||||
|
{profile.name[0]?.toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold">{profile.name}</h1>
|
||||||
|
<p className="text-muted-foreground">@{profile.username}</p>
|
||||||
|
{(profile.githubUrl || profile.xUrl) && (
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
{profile.githubUrl && (
|
||||||
|
<a
|
||||||
|
href={profile.githubUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<GitHubIcon className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{profile.xUrl && (
|
||||||
|
<a
|
||||||
|
href={profile.xUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<XIcon className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Projects */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Public Projects</h2>
|
||||||
|
{projects.length > 0 ? (
|
||||||
|
<ProjectGrid
|
||||||
|
projects={projects}
|
||||||
|
onProjectClick={(id) => router.push(`/viewer/${id}`)}
|
||||||
|
showOwner={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
No public projects yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<HubFooter />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { ArrowLeft } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { updateUsername, updateProfile } from '../lib/auth/actions'
|
||||||
|
|
||||||
|
interface SettingsPageProps {
|
||||||
|
user: {
|
||||||
|
id: string
|
||||||
|
name?: string | null
|
||||||
|
email?: string | null
|
||||||
|
image?: string | null
|
||||||
|
}
|
||||||
|
currentUsername: string | null
|
||||||
|
currentGithubUrl: string | null
|
||||||
|
currentXUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPage({
|
||||||
|
user,
|
||||||
|
currentUsername,
|
||||||
|
currentGithubUrl,
|
||||||
|
currentXUrl,
|
||||||
|
}: SettingsPageProps) {
|
||||||
|
const [username, setUsername] = useState(currentUsername ?? '')
|
||||||
|
const [githubUrl, setGithubUrl] = useState(currentGithubUrl ?? '')
|
||||||
|
const [xUrl, setXUrl] = useState(currentXUrl ?? '')
|
||||||
|
const [isSavingUsername, setIsSavingUsername] = useState(false)
|
||||||
|
const [isSavingSocial, setIsSavingSocial] = useState(false)
|
||||||
|
const [usernameMessage, setUsernameMessage] = useState<{
|
||||||
|
type: 'success' | 'error'
|
||||||
|
text: string
|
||||||
|
} | null>(null)
|
||||||
|
const [socialMessage, setSocialMessage] = useState<{
|
||||||
|
type: 'success' | 'error'
|
||||||
|
text: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
const handleSaveUsername = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setUsernameMessage(null)
|
||||||
|
setIsSavingUsername(true)
|
||||||
|
|
||||||
|
const result = await updateUsername(username)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setUsernameMessage({ type: 'success', text: 'Username updated successfully' })
|
||||||
|
} else {
|
||||||
|
setUsernameMessage({ type: 'error', text: result.error ?? 'Failed to update username' })
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingUsername(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveSocial = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSocialMessage(null)
|
||||||
|
setIsSavingSocial(true)
|
||||||
|
|
||||||
|
const result = await updateProfile({
|
||||||
|
githubUrl: githubUrl.trim() || null,
|
||||||
|
xUrl: xUrl.trim() || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setSocialMessage({ type: 'success', text: 'Social links updated successfully' })
|
||||||
|
} else {
|
||||||
|
setSocialMessage({ type: 'error', text: result.error ?? 'Failed to update social links' })
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSavingSocial(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernameChanged = username.trim() !== (currentUsername ?? '')
|
||||||
|
const socialChanged =
|
||||||
|
(githubUrl.trim() || '') !== (currentGithubUrl ?? '') ||
|
||||||
|
(xUrl.trim() || '') !== (currentXUrl ?? '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<header className="border-b border-border bg-background/95 backdrop-blur sticky top-0 z-10">
|
||||||
|
<div className="container mx-auto px-6 py-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span className="text-sm">Back</span>
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-xl font-bold">Settings</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto max-w-2xl px-6 py-8 space-y-8">
|
||||||
|
{/* Profile Section */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Profile</h2>
|
||||||
|
<div className="rounded-lg border border-border p-6 space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{user.image ? (
|
||||||
|
<Image
|
||||||
|
src={user.image}
|
||||||
|
alt={user.name || 'Profile'}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className="h-16 w-16 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted font-semibold text-lg">
|
||||||
|
{user.name?.[0]?.toUpperCase() || user.email?.[0]?.toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
{user.name && <div className="font-medium">{user.name}</div>}
|
||||||
|
{user.email && (
|
||||||
|
<div className="text-muted-foreground text-sm">{user.email}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveUsername} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="username" className="font-medium text-sm">
|
||||||
|
Public Username
|
||||||
|
</label>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Your public display name on the community hub.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUsername(e.target.value)
|
||||||
|
setUsernameMessage(null)
|
||||||
|
}}
|
||||||
|
placeholder="your-username"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isSavingUsername}
|
||||||
|
minLength={3}
|
||||||
|
maxLength={30}
|
||||||
|
pattern="[a-zA-Z0-9_-]+"
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
3-30 characters. Letters, numbers, hyphens, and underscores only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{usernameMessage && (
|
||||||
|
<div
|
||||||
|
className={`rounded-md border p-3 text-sm ${
|
||||||
|
usernameMessage.type === 'success'
|
||||||
|
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||||
|
: 'border-destructive/50 bg-destructive/10 text-destructive'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{usernameMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSavingUsername || !usernameChanged || !username.trim()}
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSavingUsername ? 'Saving...' : 'Save Username'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Social Links Section */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Social Links</h2>
|
||||||
|
<div className="rounded-lg border border-border p-6">
|
||||||
|
<form onSubmit={handleSaveSocial} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="github" className="font-medium text-sm">
|
||||||
|
GitHub
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="github"
|
||||||
|
type="url"
|
||||||
|
value={githubUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setGithubUrl(e.target.value)
|
||||||
|
setSocialMessage(null)
|
||||||
|
}}
|
||||||
|
placeholder="https://github.com/yourusername"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isSavingSocial}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="x" className="font-medium text-sm">
|
||||||
|
X (Twitter)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="x"
|
||||||
|
type="url"
|
||||||
|
value={xUrl}
|
||||||
|
onChange={(e) => {
|
||||||
|
setXUrl(e.target.value)
|
||||||
|
setSocialMessage(null)
|
||||||
|
}}
|
||||||
|
placeholder="https://x.com/yourusername"
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isSavingSocial}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{socialMessage && (
|
||||||
|
<div
|
||||||
|
className={`rounded-md border p-3 text-sm ${
|
||||||
|
socialMessage.type === 'success'
|
||||||
|
? 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||||
|
: 'border-destructive/50 bg-destructive/10 text-destructive'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{socialMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSavingSocial || !socialChanged}
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSavingSocial ? 'Saving...' : 'Save Social Links'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,22 +10,59 @@ interface SignInDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GoogleIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||||
|
fill="#4285F4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#34A853"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
fill="#FBBC05"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SignInDialog - Magic link authentication dialog
|
* SignInDialog - Authentication dialog with Google OAuth and magic link
|
||||||
*/
|
*/
|
||||||
export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
const handleGoogleSignIn = async () => {
|
||||||
|
setError(null)
|
||||||
|
setIsGoogleLoading(true)
|
||||||
|
try {
|
||||||
|
await authClient.signIn.social({
|
||||||
|
provider: 'google',
|
||||||
|
callbackURL: window.location.origin,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to sign in with Google')
|
||||||
|
setIsGoogleLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use better-auth's magic link sign in
|
|
||||||
const result = await authClient.signIn.magicLink({
|
const result = await authClient.signIn.magicLink({
|
||||||
email,
|
email,
|
||||||
callbackURL: window.location.origin,
|
callbackURL: window.location.origin,
|
||||||
@@ -45,9 +82,8 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (!isLoading) {
|
if (!isLoading && !isGoogleLoading) {
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
// Reset state after a short delay to avoid flash
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setEmail('')
|
setEmail('')
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -56,6 +92,8 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const anyLoading = isLoading || isGoogleLoading
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[400px]">
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
@@ -63,7 +101,7 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
|||||||
<DialogTitle>Sign in to Pascal</DialogTitle>
|
<DialogTitle>Sign in to Pascal</DialogTitle>
|
||||||
<button
|
<button
|
||||||
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
|
className="absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 disabled:pointer-events-none"
|
||||||
disabled={isLoading}
|
disabled={anyLoading}
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -95,6 +133,33 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Google Sign-In */}
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-md border border-input bg-background px-4 py-2.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
|
||||||
|
disabled={anyLoading}
|
||||||
|
onClick={handleGoogleSignIn}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isGoogleLoading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-foreground border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<GoogleIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Continue with Google
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<span className="w-full border-t border-border" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-background px-2 text-muted-foreground">or</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Magic Link Form */}
|
||||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="font-medium text-sm" htmlFor="email">
|
<label className="font-medium text-sm" htmlFor="email">
|
||||||
@@ -103,7 +168,7 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
|||||||
<input
|
<input
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
disabled={isLoading}
|
disabled={anyLoading}
|
||||||
id="email"
|
id="email"
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
required
|
required
|
||||||
@@ -121,7 +186,7 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
|
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
disabled={isLoading || !email}
|
disabled={anyLoading || !email}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -136,11 +201,12 @@ export function SignInDialog({ open, onOpenChange }: SignInDialogProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-muted-foreground text-xs">
|
<p className="text-center text-muted-foreground text-xs">
|
||||||
We'll send you a magic link to sign in without a password.
|
Sign in with Google or receive a magic link via email.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useAuth } from '../lib/auth/hooks'
|
||||||
|
import { getUsername } from '../lib/auth/actions'
|
||||||
|
import { UsernameOnboardingDialog } from './username-onboarding-dialog'
|
||||||
|
|
||||||
|
export function UsernameGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth()
|
||||||
|
const [needsUsername, setNeedsUsername] = useState(false)
|
||||||
|
const [checking, setChecking] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading) return
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setChecking(false)
|
||||||
|
setNeedsUsername(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
getUsername().then((username) => {
|
||||||
|
setNeedsUsername(!username)
|
||||||
|
setChecking(false)
|
||||||
|
})
|
||||||
|
}, [isAuthenticated, isLoading])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<UsernameOnboardingDialog
|
||||||
|
open={needsUsername && !checking}
|
||||||
|
onComplete={() => setNeedsUsername(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/primitives/dialog'
|
||||||
|
import { updateUsername, checkUsernameAvailability } from '../lib/auth/actions'
|
||||||
|
|
||||||
|
interface UsernameOnboardingDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsernameOnboardingDialog({ open, onComplete }: UsernameOnboardingDialogProps) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [availability, setAvailability] = useState<'idle' | 'checking' | 'available' | 'taken'>(
|
||||||
|
'idle',
|
||||||
|
)
|
||||||
|
|
||||||
|
const validate = (value: string): string | null => {
|
||||||
|
if (value.length < 3) return 'Must be at least 3 characters'
|
||||||
|
if (value.length > 30) return 'Must be at most 30 characters'
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(value))
|
||||||
|
return 'Only letters, numbers, hyphens, and underscores'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAvailability = useCallback(async (value: string) => {
|
||||||
|
const validationError = validate(value)
|
||||||
|
if (validationError) {
|
||||||
|
setAvailability('idle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAvailability('checking')
|
||||||
|
const result = await checkUsernameAvailability(value)
|
||||||
|
setAvailability(result.available ? 'available' : 'taken')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!username.trim()) {
|
||||||
|
setAvailability('idle')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => checkAvailability(username.trim()), 300)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [username, checkAvailability])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const trimmed = username.trim()
|
||||||
|
const validationError = validate(trimmed)
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
setIsSaving(true)
|
||||||
|
|
||||||
|
const result = await updateUsername(trimmed)
|
||||||
|
if (result.success) {
|
||||||
|
onComplete()
|
||||||
|
} else {
|
||||||
|
setError(result.error ?? 'Failed to set username')
|
||||||
|
}
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationError = username.trim() ? validate(username.trim()) : null
|
||||||
|
const canSubmit = !isSaving && !validationError && availability === 'available'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={() => {}}>
|
||||||
|
<DialogContent className="sm:max-w-[420px] [&>button]:hidden">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Choose your username</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Pick a public username for the community hub. This will be visible on projects you share.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-sm">
|
||||||
|
@
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUsername(e.target.value)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
placeholder="your-username"
|
||||||
|
className="w-full rounded-md border border-input bg-background pl-7 pr-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={isSaving}
|
||||||
|
autoFocus
|
||||||
|
minLength={3}
|
||||||
|
maxLength={30}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status indicators */}
|
||||||
|
{username.trim() && !validationError && (
|
||||||
|
<div className="text-xs">
|
||||||
|
{availability === 'checking' && (
|
||||||
|
<span className="text-muted-foreground">Checking availability...</span>
|
||||||
|
)}
|
||||||
|
{availability === 'available' && (
|
||||||
|
<span className="text-green-600 dark:text-green-400">Username is available</span>
|
||||||
|
)}
|
||||||
|
{availability === 'taken' && (
|
||||||
|
<span className="text-destructive">Username is already taken</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{validationError && (
|
||||||
|
<p className="text-destructive text-xs">{validationError}</p>
|
||||||
|
)}
|
||||||
|
{!username.trim() && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
3-30 characters. Letters, numbers, hyphens, and underscores only.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground text-sm font-medium transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Setting username...' : 'Continue'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
import { db, schema } from '@pascal-app/db'
|
||||||
|
import { eq, and, ne, sql } from 'drizzle-orm'
|
||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { getSession } from './server'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign in with a social provider (Google)
|
||||||
|
*/
|
||||||
|
export async function signInSocial(provider: 'google', callbackURL?: string) {
|
||||||
|
const result = await auth.api.signInSocial({
|
||||||
|
body: { provider, callbackURL: callbackURL ?? '/' },
|
||||||
|
})
|
||||||
|
revalidatePath('/')
|
||||||
|
if (result.url && result.redirect) {
|
||||||
|
redirect(result.url as '/')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current user's public username
|
||||||
|
*/
|
||||||
|
export async function updateUsername(
|
||||||
|
username: string,
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user) {
|
||||||
|
return { success: false, error: 'Not authenticated' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate username format
|
||||||
|
const trimmed = username.trim()
|
||||||
|
if (trimmed.length < 3) {
|
||||||
|
return { success: false, error: 'Username must be at least 3 characters' }
|
||||||
|
}
|
||||||
|
if (trimmed.length > 30) {
|
||||||
|
return { success: false, error: 'Username must be at most 30 characters' }
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Username can only contain letters, numbers, hyphens, and underscores',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username is already taken (case-insensitive)
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: schema.users.id })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
sql`lower(${schema.users.username}) = lower(${trimmed})`,
|
||||||
|
ne(schema.users.id, session.user.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return { success: false, error: 'Username is already taken' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.users)
|
||||||
|
.set({ username: trimmed })
|
||||||
|
.where(eq(schema.users.id, session.user.id))
|
||||||
|
|
||||||
|
revalidatePath('/')
|
||||||
|
revalidatePath('/settings')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current user's username
|
||||||
|
*/
|
||||||
|
export async function getUsername(): Promise<string | null> {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user) return null
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select({ username: schema.users.username })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.id, session.user.id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return result[0]?.username ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a username is available
|
||||||
|
*/
|
||||||
|
export async function checkUsernameAvailability(
|
||||||
|
username: string,
|
||||||
|
): Promise<{ available: boolean }> {
|
||||||
|
const trimmed = username.trim()
|
||||||
|
if (trimmed.length < 3 || !/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||||
|
return { available: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: schema.users.id })
|
||||||
|
.from(schema.users)
|
||||||
|
.where(sql`lower(${schema.users.username}) = lower(${trimmed})`)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return { available: existing.length === 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current user's full profile
|
||||||
|
*/
|
||||||
|
export async function getUserProfile(): Promise<{
|
||||||
|
username: string | null
|
||||||
|
githubUrl: string | null
|
||||||
|
xUrl: string | null
|
||||||
|
} | null> {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user) return null
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
username: schema.users.username,
|
||||||
|
githubUrl: schema.users.githubUrl,
|
||||||
|
xUrl: schema.users.xUrl,
|
||||||
|
})
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.id, session.user.id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
return result[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current user's social profile links
|
||||||
|
*/
|
||||||
|
export async function updateProfile(data: {
|
||||||
|
githubUrl?: string | null
|
||||||
|
xUrl?: string | null
|
||||||
|
}): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session?.user) {
|
||||||
|
return { success: false, error: 'Not authenticated' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.githubUrl && !/^https:\/\/(www\.)?github\.com\/.+/.test(data.githubUrl)) {
|
||||||
|
return { success: false, error: 'Invalid GitHub URL' }
|
||||||
|
}
|
||||||
|
if (data.xUrl && !/^https:\/\/(www\.)?(x|twitter)\.com\/.+/.test(data.xUrl)) {
|
||||||
|
return { success: false, error: 'Invalid X/Twitter URL' }
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(schema.users)
|
||||||
|
.set({
|
||||||
|
githubUrl: data.githubUrl ?? null,
|
||||||
|
xUrl: data.xUrl ?? null,
|
||||||
|
})
|
||||||
|
.where(eq(schema.users.id, session.user.id))
|
||||||
|
|
||||||
|
revalidatePath('/settings')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user's public profile by username
|
||||||
|
*/
|
||||||
|
export async function getPublicProfile(username: string): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
image: string | null
|
||||||
|
username: string
|
||||||
|
githubUrl: string | null
|
||||||
|
xUrl: string | null
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
id: schema.users.id,
|
||||||
|
name: schema.users.name,
|
||||||
|
image: schema.users.image,
|
||||||
|
username: schema.users.username,
|
||||||
|
githubUrl: schema.users.githubUrl,
|
||||||
|
xUrl: schema.users.xUrl,
|
||||||
|
})
|
||||||
|
.from(schema.users)
|
||||||
|
.where(sql`lower(${schema.users.username}) = lower(${username})`)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
const user = result[0]
|
||||||
|
if (!user || !user.username) {
|
||||||
|
return { success: false, error: 'User not found' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: user as typeof user & { username: string } }
|
||||||
|
}
|
||||||
@@ -397,7 +397,8 @@ export async function getPublicProjects(): Promise<ActionResult<Project[]>> {
|
|||||||
.from('projects')
|
.from('projects')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
address:projects_addresses(*)
|
address:projects_addresses(*),
|
||||||
|
owner:auth_users!owner_id(id, name, username, image)
|
||||||
`)
|
`)
|
||||||
.eq('is_private', false)
|
.eq('is_private', false)
|
||||||
.order('views', { ascending: false })
|
.order('views', { ascending: false })
|
||||||
@@ -424,6 +425,37 @@ export async function getPublicProjects(): Promise<ActionResult<Project[]>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch public projects for a specific user (by user ID)
|
||||||
|
*/
|
||||||
|
export async function getPublicProjectsByUserId(userId: string): Promise<ActionResult<Project[]>> {
|
||||||
|
try {
|
||||||
|
const supabase = await createServerSupabaseClient()
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('projects')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
address:projects_addresses(*)
|
||||||
|
`)
|
||||||
|
.eq('owner_id', userId)
|
||||||
|
.eq('is_private', false)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { success: false, error: error.message, data: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: data as Project[] }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch projects',
|
||||||
|
data: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a project model for viewing
|
* Get a project model for viewing
|
||||||
* Allows viewing if: project is public OR user owns the project
|
* Allows viewing if: project is public OR user owns the project
|
||||||
@@ -440,7 +472,8 @@ export async function getProjectModelPublic(projectId: string): Promise<
|
|||||||
.from('projects')
|
.from('projects')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
address:projects_addresses(*)
|
address:projects_addresses(*),
|
||||||
|
owner:auth_users!owner_id(id, name, username, image)
|
||||||
`)
|
`)
|
||||||
.eq('id', projectId)
|
.eq('id', projectId)
|
||||||
.single()
|
.single()
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProjectOwner = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
username: string | null
|
||||||
|
image: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -111,6 +118,7 @@ export type Project = {
|
|||||||
latitude?: string
|
latitude?: string
|
||||||
longitude?: string
|
longitude?: string
|
||||||
} | null
|
} | null
|
||||||
|
owner?: ProjectOwner | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateProjectParams = {
|
export type CreateProjectParams = {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const auth = createAuth({
|
|||||||
appName: 'Pascal Editor',
|
appName: 'Pascal Editor',
|
||||||
baseURL: BASE_URL,
|
baseURL: BASE_URL,
|
||||||
secret: process.env.BETTER_AUTH_SECRET!,
|
secret: process.env.BETTER_AUTH_SECRET!,
|
||||||
|
googleClientId: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
sendMagicLink: async ({ email, url }) => {
|
sendMagicLink: async ({ email, url }) => {
|
||||||
if (!resend) {
|
if (!resend) {
|
||||||
console.log(`[DEV] Magic link for ${email}: ${url}`)
|
console.log(`[DEV] Magic link for ${email}: ${url}`)
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
"check:fix": "biome check --write",
|
"check:fix": "biome check --write",
|
||||||
"check-types": "turbo run check-types",
|
"check-types": "turbo run check-types",
|
||||||
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'",
|
"kill": "lsof -ti:3002 | xargs kill -9 2>/dev/null || echo 'No processes found on port 3002'",
|
||||||
|
"db:generate": "bun run --cwd packages/db db:generate",
|
||||||
|
"db:migrate": "bun run --cwd packages/db db:migrate",
|
||||||
|
"db:push": "bun run --cwd packages/db db:push",
|
||||||
|
"db:studio": "bun run --cwd packages/db db:studio",
|
||||||
"db:start": "supabase start",
|
"db:start": "supabase start",
|
||||||
"db:stop": "supabase stop",
|
"db:stop": "supabase stop",
|
||||||
"db:reset": "supabase db reset",
|
"db:reset": "supabase db reset",
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export interface AuthConfig {
|
|||||||
appName: string
|
appName: string
|
||||||
baseURL: string
|
baseURL: string
|
||||||
secret: string
|
secret: string
|
||||||
|
/** Google OAuth client ID */
|
||||||
|
googleClientId?: string
|
||||||
|
/** Google OAuth client secret */
|
||||||
|
googleClientSecret?: string
|
||||||
/** Callback to send magic link emails */
|
/** Callback to send magic link emails */
|
||||||
sendMagicLink?: (params: SendMagicLinkParams) => Promise<void>
|
sendMagicLink?: (params: SendMagicLinkParams) => Promise<void>
|
||||||
/** Additional plugins to add (e.g., nextCookies for web) */
|
/** Additional plugins to add (e.g., nextCookies for web) */
|
||||||
@@ -57,6 +61,22 @@ export function createAuth(config: AuthConfig): ReturnType<typeof betterAuth> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Google OAuth provider (only enabled when credentials are provided)
|
||||||
|
...(config.googleClientId &&
|
||||||
|
config.googleClientSecret && {
|
||||||
|
socialProviders: {
|
||||||
|
google: {
|
||||||
|
clientId: config.googleClientId,
|
||||||
|
clientSecret: config.googleClientSecret,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
accountLinking: {
|
||||||
|
enabled: true,
|
||||||
|
trustedProviders: ['google'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
plugins: [
|
plugins: [
|
||||||
...(config.additionalPlugins ?? []),
|
...(config.additionalPlugins ?? []),
|
||||||
// Magic link authentication
|
// Magic link authentication
|
||||||
|
|||||||
@@ -13,13 +13,22 @@ export const users = pgTable(
|
|||||||
emailVerified: t.boolean('email_verified').notNull().default(false),
|
emailVerified: t.boolean('email_verified').notNull().default(false),
|
||||||
name: t.text('name').notNull(),
|
name: t.text('name').notNull(),
|
||||||
image: t.text('image'),
|
image: t.text('image'),
|
||||||
|
/** Public username for the community hub */
|
||||||
|
username: t.text('username'),
|
||||||
|
/** GitHub profile URL */
|
||||||
|
githubUrl: t.text('github_url'),
|
||||||
|
/** X/Twitter profile URL */
|
||||||
|
xUrl: t.text('x_url'),
|
||||||
role: userRoles('role').notNull().default('user'),
|
role: userRoles('role').notNull().default('user'),
|
||||||
banned: t.boolean('banned').notNull().default(false),
|
banned: t.boolean('banned').notNull().default(false),
|
||||||
banReason: t.text('ban_reason'),
|
banReason: t.text('ban_reason'),
|
||||||
banExpires: t.timestamp('ban_expires', { withTimezone: true }),
|
banExpires: t.timestamp('ban_expires', { withTimezone: true }),
|
||||||
...timestampsColumns,
|
...timestampsColumns,
|
||||||
}),
|
}),
|
||||||
(t) => [uniqueIndex('email_unique_index').on(lower(t.email))],
|
(t) => [
|
||||||
|
uniqueIndex('email_unique_index').on(lower(t.email)),
|
||||||
|
uniqueIndex('username_unique_index').on(lower(t.username)),
|
||||||
|
],
|
||||||
).enableRLS()
|
).enableRLS()
|
||||||
|
|
||||||
export type User = typeof users.$inferSelect
|
export type User = typeof users.$inferSelect
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "auth_users" ADD COLUMN "username" text;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "username_unique_index" ON "auth_users" USING btree (lower("username"));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "auth_users" ADD COLUMN "github_url" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "auth_users" ADD COLUMN "x_url" text;
|
||||||
@@ -0,0 +1,967 @@
|
|||||||
|
{
|
||||||
|
"id": "a70e4c16-59a9-4c80-81db-fb43206f7eeb",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.auth_accounts": {
|
||||||
|
"name": "auth_accounts",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"auth_accounts_user_id_auth_users_id_fk": {
|
||||||
|
"name": "auth_accounts_user_id_auth_users_id_fk",
|
||||||
|
"tableFrom": "auth_accounts",
|
||||||
|
"tableTo": "auth_users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.auth_jwks": {
|
||||||
|
"name": "auth_jwks",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"public_key": {
|
||||||
|
"name": "public_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"private_key": {
|
||||||
|
"name": "private_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.auth_sessions": {
|
||||||
|
"name": "auth_sessions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"active_project_id": {
|
||||||
|
"name": "active_project_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"impersonated_by": {
|
||||||
|
"name": "impersonated_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"auth_sessions_user_id_auth_users_id_fk": {
|
||||||
|
"name": "auth_sessions_user_id_auth_users_id_fk",
|
||||||
|
"tableFrom": "auth_sessions",
|
||||||
|
"tableTo": "auth_users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"auth_sessions_impersonated_by_auth_users_id_fk": {
|
||||||
|
"name": "auth_sessions_impersonated_by_auth_users_id_fk",
|
||||||
|
"tableFrom": "auth_sessions",
|
||||||
|
"tableTo": "auth_users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"impersonated_by"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.auth_users": {
|
||||||
|
"name": "auth_users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "auth_user_roles",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'user'"
|
||||||
|
},
|
||||||
|
"banned": {
|
||||||
|
"name": "banned",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"ban_reason": {
|
||||||
|
"name": "ban_reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ban_expires": {
|
||||||
|
"name": "ban_expires",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"email_unique_index": {
|
||||||
|
"name": "email_unique_index",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "lower(\"email\")",
|
||||||
|
"asc": true,
|
||||||
|
"isExpression": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.auth_verifications": {
|
||||||
|
"name": "auth_verifications",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"verification_identifier_index": {
|
||||||
|
"name": "verification_identifier_index",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "identifier",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.feedback": {
|
||||||
|
"name": "feedback",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.projects_addresses": {
|
||||||
|
"name": "projects_addresses",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"street_number": {
|
||||||
|
"name": "street_number",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"route": {
|
||||||
|
"name": "route",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"route_short": {
|
||||||
|
"name": "route_short",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"neighborhood": {
|
||||||
|
"name": "neighborhood",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"name": "city",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"county": {
|
||||||
|
"name": "county",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"state_long": {
|
||||||
|
"name": "state_long",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"postal_code": {
|
||||||
|
"name": "postal_code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"postal_code_suffix": {
|
||||||
|
"name": "postal_code_suffix",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"name": "country",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"country_long": {
|
||||||
|
"name": "country_long",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"name": "latitude",
|
||||||
|
"type": "numeric",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"name": "longitude",
|
||||||
|
"type": "numeric",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"raw_json": {
|
||||||
|
"name": "raw_json",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"address_components_unique": {
|
||||||
|
"name": "address_components_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"street_number",
|
||||||
|
"route",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"postal_code"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.projects_likes": {
|
||||||
|
"name": "projects_likes",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"projects_likes_project_id_projects_id_fk": {
|
||||||
|
"name": "projects_likes_project_id_projects_id_fk",
|
||||||
|
"tableFrom": "projects_likes",
|
||||||
|
"tableTo": "projects",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"projects_likes_project_user_unique": {
|
||||||
|
"name": "projects_likes_project_user_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"project_id",
|
||||||
|
"user_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.projects_models": {
|
||||||
|
"name": "projects_models",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"name": "version",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"draft": {
|
||||||
|
"name": "draft",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scene_graph": {
|
||||||
|
"name": "scene_graph",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"deleted_at": {
|
||||||
|
"name": "deleted_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"projects_models_project_id_projects_id_fk": {
|
||||||
|
"name": "projects_models_project_id_projects_id_fk",
|
||||||
|
"tableFrom": "projects_models",
|
||||||
|
"tableTo": "projects",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.projects": {
|
||||||
|
"name": "projects",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"address_id": {
|
||||||
|
"name": "address_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"details_json": {
|
||||||
|
"name": "details_json",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_private": {
|
||||||
|
"name": "is_private",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"name": "views",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"likes": {
|
||||||
|
"name": "likes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"thumbnail_url": {
|
||||||
|
"name": "thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"project_address_idx": {
|
||||||
|
"name": "project_address_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "address_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"project_owner_idx": {
|
||||||
|
"name": "project_owner_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "owner_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"project_is_private_idx": {
|
||||||
|
"name": "project_is_private_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "is_private",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"project_views_idx": {
|
||||||
|
"name": "project_views_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "views",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"project_likes_idx": {
|
||||||
|
"name": "project_likes_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "likes",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"projects_address_id_projects_addresses_id_fk": {
|
||||||
|
"name": "projects_address_id_projects_addresses_id_fk",
|
||||||
|
"tableFrom": "projects",
|
||||||
|
"tableTo": "projects_addresses",
|
||||||
|
"columnsFrom": [
|
||||||
|
"address_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"projects_owner_id_auth_users_id_fk": {
|
||||||
|
"name": "projects_owner_id_auth_users_id_fk",
|
||||||
|
"tableFrom": "projects",
|
||||||
|
"tableTo": "auth_users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"owner_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.auth_user_roles": {
|
||||||
|
"name": "auth_user_roles",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"user",
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,988 @@
|
|||||||
|
{
|
||||||
|
"id": "a414a135-fe6d-4192-9d7c-52a09abead20",
|
||||||
|
"prevId": "a70e4c16-59a9-4c80-81db-fb43206f7eeb",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.auth_accounts": {
|
||||||
|
"name": "auth_accounts",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"auth_accounts_user_id_auth_users_id_fk": {
|
||||||
|
"name": "auth_accounts_user_id_auth_users_id_fk",
|
||||||
|
"tableFrom": "auth_accounts",
|
||||||
|
"tableTo": "auth_users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.auth_jwks": {
|
||||||
|
"name": "auth_jwks",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"public_key": {
|
||||||
|
"name": "public_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"private_key": {
|
||||||
|
"name": "private_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.auth_sessions": {
|
||||||
|
"name": "auth_sessions",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"active_project_id": {
|
||||||
|
"name": "active_project_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"impersonated_by": {
|
||||||
|
"name": "impersonated_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"auth_sessions_user_id_auth_users_id_fk": {
|
||||||
|
"name": "auth_sessions_user_id_auth_users_id_fk",
|
||||||
|
"tableFrom": "auth_sessions",
|
||||||
|
"tableTo": "auth_users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"auth_sessions_impersonated_by_auth_users_id_fk": {
|
||||||
|
"name": "auth_sessions_impersonated_by_auth_users_id_fk",
|
||||||
|
"tableFrom": "auth_sessions",
|
||||||
|
"tableTo": "auth_users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"impersonated_by"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.auth_users": {
|
||||||
|
"name": "auth_users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "auth_user_roles",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'user'"
|
||||||
|
},
|
||||||
|
"banned": {
|
||||||
|
"name": "banned",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"ban_reason": {
|
||||||
|
"name": "ban_reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ban_expires": {
|
||||||
|
"name": "ban_expires",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"email_unique_index": {
|
||||||
|
"name": "email_unique_index",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "lower(\"email\")",
|
||||||
|
"asc": true,
|
||||||
|
"isExpression": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"username_unique_index": {
|
||||||
|
"name": "username_unique_index",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "lower(\"username\")",
|
||||||
|
"asc": true,
|
||||||
|
"isExpression": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.auth_verifications": {
|
||||||
|
"name": "auth_verifications",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"verification_identifier_index": {
|
||||||
|
"name": "verification_identifier_index",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "identifier",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.feedback": {
|
||||||
|
"name": "feedback",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"name": "message",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.projects_addresses": {
|
||||||
|
"name": "projects_addresses",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"street_number": {
|
||||||
|
"name": "street_number",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"route": {
|
||||||
|
"name": "route",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"route_short": {
|
||||||
|
"name": "route_short",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"neighborhood": {
|
||||||
|
"name": "neighborhood",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"name": "city",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"county": {
|
||||||
|
"name": "county",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"state_long": {
|
||||||
|
"name": "state_long",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"postal_code": {
|
||||||
|
"name": "postal_code",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"postal_code_suffix": {
|
||||||
|
"name": "postal_code_suffix",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"name": "country",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"country_long": {
|
||||||
|
"name": "country_long",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"name": "latitude",
|
||||||
|
"type": "numeric",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"name": "longitude",
|
||||||
|
"type": "numeric",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"raw_json": {
|
||||||
|
"name": "raw_json",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"address_components_unique": {
|
||||||
|
"name": "address_components_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"street_number",
|
||||||
|
"route",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"postal_code"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.projects_likes": {
|
||||||
|
"name": "projects_likes",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"projects_likes_project_id_projects_id_fk": {
|
||||||
|
"name": "projects_likes_project_id_projects_id_fk",
|
||||||
|
"tableFrom": "projects_likes",
|
||||||
|
"tableTo": "projects",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"projects_likes_project_user_unique": {
|
||||||
|
"name": "projects_likes_project_user_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"project_id",
|
||||||
|
"user_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.projects_models": {
|
||||||
|
"name": "projects_models",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"name": "version",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"draft": {
|
||||||
|
"name": "draft",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scene_graph": {
|
||||||
|
"name": "scene_graph",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"deleted_at": {
|
||||||
|
"name": "deleted_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"projects_models_project_id_projects_id_fk": {
|
||||||
|
"name": "projects_models_project_id_projects_id_fk",
|
||||||
|
"tableFrom": "projects_models",
|
||||||
|
"tableTo": "projects",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
},
|
||||||
|
"public.projects": {
|
||||||
|
"name": "projects",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"address_id": {
|
||||||
|
"name": "address_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"details_json": {
|
||||||
|
"name": "details_json",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "jsonb",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_private": {
|
||||||
|
"name": "is_private",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"name": "views",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"likes": {
|
||||||
|
"name": "likes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"thumbnail_url": {
|
||||||
|
"name": "thumbnail_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"project_address_idx": {
|
||||||
|
"name": "project_address_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "address_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"project_owner_idx": {
|
||||||
|
"name": "project_owner_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "owner_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"project_is_private_idx": {
|
||||||
|
"name": "project_is_private_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "is_private",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"project_views_idx": {
|
||||||
|
"name": "project_views_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "views",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"project_likes_idx": {
|
||||||
|
"name": "project_likes_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "likes",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"projects_address_id_projects_addresses_id_fk": {
|
||||||
|
"name": "projects_address_id_projects_addresses_id_fk",
|
||||||
|
"tableFrom": "projects",
|
||||||
|
"tableTo": "projects_addresses",
|
||||||
|
"columnsFrom": [
|
||||||
|
"address_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"projects_owner_id_auth_users_id_fk": {
|
||||||
|
"name": "projects_owner_id_auth_users_id_fk",
|
||||||
|
"tableFrom": "projects",
|
||||||
|
"tableTo": "auth_users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"owner_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.auth_user_roles": {
|
||||||
|
"name": "auth_user_roles",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"user",
|
||||||
|
"admin"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user