frontend changes
This commit is contained in:
parent
0831dd5658
commit
68bec83ba4
@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useState, Suspense } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@ -44,7 +44,8 @@ interface FileAnalysis {
|
||||
details?: string
|
||||
}
|
||||
|
||||
export default function AIAnalysisPage() {
|
||||
// Component that uses useSearchParams - needs to be wrapped in Suspense
|
||||
function AIAnalysisContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const repoId = searchParams.get('repoId')
|
||||
@ -318,3 +319,11 @@ export default function AIAnalysisPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AIAnalysisPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="mx-auto max-w-7xl px-4 py-8 flex items-center justify-center min-h-screen"><div className="text-white">Loading analysis...</div></div>}>
|
||||
<AIAnalysisContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import RepoByIdClient from "./repo-client"
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const dynamicParams = true
|
||||
export function generateStaticParams() { return [] }
|
||||
|
||||
export default function Page({ params, searchParams }: { params: { id: string }, searchParams?: { path?: string } }) {
|
||||
const id = params.id
|
||||
const path = (searchParams?.path as string) || ""
|
||||
return <RepoByIdClient repositoryId={id} initialPath={path} />
|
||||
}
|
||||
@ -1,125 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { getRepositoryStructure, getRepositoryFileContent, type RepoStructureEntry } from "@/lib/api/github"
|
||||
import { ArrowLeft, BookText, Clock, Code, FileText, Folder, GitBranch, Search } from "lucide-react"
|
||||
|
||||
export default function RepoByIdClient({ repositoryId, initialPath = "" }: { repositoryId: string, initialPath?: string }) {
|
||||
const [path, setPath] = useState(initialPath)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [entries, setEntries] = useState<RepoStructureEntry[]>([])
|
||||
const [fileQuery, setFileQuery] = useState("")
|
||||
const [readme, setReadme] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
;(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const struct = await getRepositoryStructure(repositoryId, path)
|
||||
if (!mounted) return
|
||||
setEntries(struct?.structure || [])
|
||||
// try README at this path
|
||||
const candidates = ["README.md", "readme.md", "README.MD"]
|
||||
for (const name of candidates) {
|
||||
try {
|
||||
const fp = path ? `${path}/${name}` : name
|
||||
const content = await getRepositoryFileContent(repositoryId, fp)
|
||||
if (content?.content) { setReadme(content.content); break }
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { mounted = false }
|
||||
}, [repositoryId, path])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = fileQuery.toLowerCase()
|
||||
if (!q) return entries
|
||||
return entries.filter(e => e.name.toLowerCase().includes(q))
|
||||
}, [entries, fileQuery])
|
||||
|
||||
const navigateFolder = (name: string) => {
|
||||
const next = path ? `${path}/${name}` : name
|
||||
setPath(next)
|
||||
}
|
||||
|
||||
const goUp = () => {
|
||||
if (!path) return
|
||||
const parts = path.split("/")
|
||||
parts.pop()
|
||||
setPath(parts.join("/"))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/github/repos">
|
||||
<Button variant="ghost" className="text-white/80 hover:text-white">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Repos
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-semibold truncate">Repository #{repositoryId}</h1>
|
||||
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-emerald-900/40 text-emerald-300 border border-emerald-800">Attached</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-2">
|
||||
<Button variant="outline" className="h-8 text-sm border-white/15 text-white bg-white/5 hover:bg-white/10">
|
||||
<GitBranch className="h-4 w-4 mr-2"/> main
|
||||
</Button>
|
||||
<div className="relative ml-auto w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-white/40"/>
|
||||
<Input value={fileQuery} onChange={e=>setFileQuery(e.target.value)} placeholder="Go to file" className="pl-9 h-8 text-sm bg-white/5 border-white/10 text-white placeholder:text-white/40"/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={goUp} className="h-8 text-sm border-white/15 text-white bg-white/5 hover:bg-white/10">Up one level</Button>
|
||||
<Button className="h-8 text-sm bg-emerald-600 hover:bg-emerald-500 text-black">
|
||||
<Code className="h-4 w-4 mr-2"/> Code
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-white/5 border-white/10 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{loading && (
|
||||
<div className="px-4 py-6 text-white/60">Loading...</div>
|
||||
)}
|
||||
{!loading && visible.length === 0 && (
|
||||
<div className="px-4 py-6 text-white/60">No entries found.</div>
|
||||
)}
|
||||
{visible.map((e, i) => (
|
||||
<div key={i} className="flex items-center px-4 py-3 border-b border-white/10 hover:bg-white/5 cursor-pointer"
|
||||
onClick={() => e.type === 'directory' ? navigateFolder(e.name) : void 0}>
|
||||
<div className="w-7 flex justify-center">
|
||||
{e.type === 'directory' ? <Folder className="h-4 w-4"/> : <FileText className="h-4 w-4"/>}
|
||||
</div>
|
||||
<div className="flex-1 font-medium truncate">{e.name}</div>
|
||||
<div className="w-40 text-right text-sm text-white/60 flex items-center justify-end gap-1">
|
||||
<Clock className="h-4 w-4"/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardContent className="p-0">
|
||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold">README</div>
|
||||
{!readme ? (
|
||||
<div className="p-8 text-center">
|
||||
<BookText className="h-10 w-10 mx-auto text-white/60"/>
|
||||
<h3 className="mt-3 text-xl font-semibold">No README found</h3>
|
||||
<p className="mt-1 text-white/60 text-sm">Add a README.md to the repository to show it here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-4 whitespace-pre-wrap text-sm text-white/90">{readme}</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,10 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, Suspense } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import RepoByIdClient from "./repo-client"
|
||||
|
||||
export default function Page({ searchParams }: { searchParams?: { id?: string; path?: string } }) {
|
||||
const id = (searchParams?.id as string) || ""
|
||||
const path = (searchParams?.path as string) || ""
|
||||
// Component that uses useSearchParams - needs to be wrapped in Suspense
|
||||
function RepoPageContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [repositoryId, setRepositoryId] = useState<string>("")
|
||||
const [initialPath, setInitialPath] = useState<string>("")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const id = searchParams.get('id')
|
||||
const path = searchParams.get('path') || ""
|
||||
|
||||
if (id) {
|
||||
setRepositoryId(id)
|
||||
setInitialPath(path)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}, [searchParams])
|
||||
|
||||
if (!id) {
|
||||
if (isLoading) {
|
||||
return <div className="mx-auto max-w-3xl px-4 py-10 text-white/80">Loading...</div>
|
||||
}
|
||||
|
||||
if (!repositoryId) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-10 text-white/80">
|
||||
<h1 className="text-2xl font-semibold">Repository</h1>
|
||||
@ -13,5 +36,13 @@ export default function Page({ searchParams }: { searchParams?: { id?: string; p
|
||||
)
|
||||
}
|
||||
|
||||
return <RepoByIdClient repositoryId={id} initialPath={path} />
|
||||
return <RepoByIdClient repositoryId={repositoryId} initialPath={initialPath} />
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={<div className="mx-auto max-w-3xl px-4 py-10 text-white/80">Loading...</div>}>
|
||||
<RepoPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,9 +5,8 @@ import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { getRepositoryStructure, getRepositoryFileContent, getRepositoryCommitSummary, type RepoStructureEntry, type CommitSummaryResponse } from "@/lib/api/github"
|
||||
import { ArrowLeft, BookText, Clock, Code, FileText, Folder, GitBranch, Search, GitCommit } from "lucide-react"
|
||||
import RepoTree from "./RepoTree"
|
||||
import { getRepositoryStructure, getRepositoryFileContent, type RepoStructureEntry } from "@/lib/api/github"
|
||||
import { ArrowLeft, BookText, Clock, Code, FileText, Folder, GitBranch, Search } from "lucide-react"
|
||||
|
||||
export default function RepoByIdClient({ repositoryId, initialPath = "" }: { repositoryId: string, initialPath?: string }) {
|
||||
const [path, setPath] = useState(initialPath)
|
||||
@ -15,30 +14,6 @@ export default function RepoByIdClient({ repositoryId, initialPath = "" }: { rep
|
||||
const [entries, setEntries] = useState<RepoStructureEntry[]>([])
|
||||
const [fileQuery, setFileQuery] = useState("")
|
||||
const [readme, setReadme] = useState<string | null>(null)
|
||||
const [commitSummary, setCommitSummary] = useState<CommitSummaryResponse | null>(null)
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewContent, setPreviewContent] = useState<string | null>(null)
|
||||
const [selectedDir, setSelectedDir] = useState<string>("")
|
||||
const [dirEntries, setDirEntries] = useState<RepoStructureEntry[]>([])
|
||||
|
||||
const timeAgo = (iso?: string) => {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso).getTime()
|
||||
const diff = Math.max(0, Date.now() - d)
|
||||
const s = Math.floor(diff/1000)
|
||||
if (s < 60) return `${s}s ago`
|
||||
const m = Math.floor(s/60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.floor(m/60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
const days = Math.floor(h/24)
|
||||
if (days < 30) return `${days}d ago`
|
||||
const mo = Math.floor(days/30)
|
||||
if (mo < 12) return `${mo}mo ago`
|
||||
const y = Math.floor(mo/12)
|
||||
return `${y}y ago`
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
@ -48,23 +23,14 @@ export default function RepoByIdClient({ repositoryId, initialPath = "" }: { rep
|
||||
const struct = await getRepositoryStructure(repositoryId, path)
|
||||
if (!mounted) return
|
||||
setEntries(struct?.structure || [])
|
||||
// Only fetch README if it exists in the current directory listing
|
||||
const readmeEntry = (struct?.structure || []).find(e => e.type === 'file' && /^readme\.(md|markdown)$/i.test(e.name))
|
||||
if (readmeEntry) {
|
||||
// try README at this path
|
||||
const candidates = ["README.md", "readme.md", "README.MD"]
|
||||
for (const name of candidates) {
|
||||
try {
|
||||
const fp = path ? `${path}/${readmeEntry.name}` : readmeEntry.name
|
||||
const fp = path ? `${path}/${name}` : name
|
||||
const content = await getRepositoryFileContent(repositoryId, fp)
|
||||
if (content?.content) { setReadme(content.content) } else { setReadme(null) }
|
||||
} catch { setReadme(null) }
|
||||
} else {
|
||||
setReadme(null)
|
||||
}
|
||||
// fetch commit summary (only on root path)
|
||||
if (!path) {
|
||||
try {
|
||||
const summary = await getRepositoryCommitSummary(repositoryId)
|
||||
if (mounted) setCommitSummary(summary)
|
||||
} catch { /* ignore */ }
|
||||
if (content?.content) { setReadme(content.content); break }
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@ -79,9 +45,9 @@ export default function RepoByIdClient({ repositoryId, initialPath = "" }: { rep
|
||||
return entries.filter(e => e.name.toLowerCase().includes(q))
|
||||
}, [entries, fileQuery])
|
||||
|
||||
const navigateFolder = (rel: string) => {
|
||||
// rel is expected to be a relative path from repo root
|
||||
setPath(rel)
|
||||
const navigateFolder = (name: string) => {
|
||||
const next = path ? `${path}/${name}` : name
|
||||
setPath(next)
|
||||
}
|
||||
|
||||
const goUp = () => {
|
||||
@ -117,135 +83,28 @@ export default function RepoByIdClient({ repositoryId, initialPath = "" }: { rep
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Commit summary row */}
|
||||
{commitSummary?.last_commit && (
|
||||
<div className="bg-white/5 border border-white/10 rounded-lg px-4 py-3 flex items-center gap-3">
|
||||
<div className="rounded-full bg-white/10 p-2"><GitCommit className="h-4 w-4 text-white"/></div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-white truncate">
|
||||
<span className="font-semibold">{commitSummary.last_commit.author_name}</span>
|
||||
<span className="mx-2 text-white/60">{commitSummary.last_commit.message}</span>
|
||||
</div>
|
||||
<div className="text-xs text-white/60">
|
||||
{commitSummary.last_commit.short_hash} • {timeAgo(commitSummary.last_commit.committed_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-white/80 shrink-0">
|
||||
<span className="px-2 py-1 rounded-full bg-white/10 border border-white/10">{commitSummary.total_commits} commits</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Three-column layout: left tree, middle directory listing, right preview */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||
<div className="lg:col-span-1 bg-white/5 border border-white/10 rounded-lg p-2 overflow-auto max-h-[70vh]">
|
||||
<RepoTree
|
||||
repositoryId={repositoryId}
|
||||
rootPath=""
|
||||
onSelectDirectory={async (dirPath) => {
|
||||
// Update center panel with this directory contents
|
||||
setSelectedDir(dirPath)
|
||||
try {
|
||||
const res = await getRepositoryStructure(repositoryId, dirPath)
|
||||
setDirEntries(res?.structure || [])
|
||||
} catch {
|
||||
setDirEntries([])
|
||||
}
|
||||
}}
|
||||
onSelectFile={async (filePath) => {
|
||||
setSelectedFile(filePath)
|
||||
try {
|
||||
setPreviewLoading(true)
|
||||
const file = await getRepositoryFileContent(repositoryId, filePath)
|
||||
setPreviewContent(file?.content || null)
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Middle: Directory listing */}
|
||||
<div className="lg:col-span-1 bg-white/5 border border-white/10 rounded-lg overflow-hidden">
|
||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold flex items-center justify-between">
|
||||
<span>Folder</span>
|
||||
<span className="text-xs text-white/60 truncate ml-2">{selectedDir || "/"}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-white/10">
|
||||
{dirEntries.length === 0 && (
|
||||
<div className="px-4 py-6 text-white/60">Select a folder from the tree.</div>
|
||||
)}
|
||||
{dirEntries.map((e, i) => {
|
||||
const rawPath = (e as any).path || (e as any).relative_path || ''
|
||||
const displayName = (e as any).name || (e as any).filename || (rawPath ? rawPath.split('/').slice(-1)[0] : '(unknown)')
|
||||
const typeVal = (e as any).type || (e as any).entry_type
|
||||
const isDirHint = typeVal === 'directory' || typeVal === 'dir' || (e as any).is_directory === true || (e as any).isDir === true
|
||||
// heuristic fallback if type missing: no dot in last segment often means folder
|
||||
const isDir = isDirHint || (!!displayName && !displayName.includes('.'))
|
||||
return (
|
||||
<div key={(e as any).path || (e as any).relative_path || displayName + i} className="flex items-center px-4 py-3 hover:bg-white/5 cursor-pointer"
|
||||
onClick={async () => {
|
||||
const relPath = (e as any).path || (e as any).relative_path || displayName
|
||||
if (isDir) {
|
||||
// drill down in tree and center
|
||||
setSelectedDir(relPath)
|
||||
try {
|
||||
const res = await getRepositoryStructure(repositoryId, relPath)
|
||||
setDirEntries(res?.structure || [])
|
||||
} catch {
|
||||
setDirEntries([])
|
||||
}
|
||||
return
|
||||
}
|
||||
// file
|
||||
setSelectedFile(relPath)
|
||||
try {
|
||||
setPreviewLoading(true)
|
||||
let file = await getRepositoryFileContent(repositoryId, relPath)
|
||||
// if backend still reports directory, probe and navigate instead of showing error
|
||||
if (!file?.content) {
|
||||
try {
|
||||
const probe = await getRepositoryStructure(repositoryId, relPath)
|
||||
if (Array.isArray(probe?.structure) && probe.structure.length > 0) {
|
||||
setPreviewContent(null)
|
||||
setSelectedFile(null)
|
||||
setSelectedDir(relPath)
|
||||
setDirEntries(probe.structure)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setPreviewContent(file?.content || null)
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
}
|
||||
}}>
|
||||
<div className="w-7 flex justify-center">
|
||||
{isDir ? <Folder className="h-4 w-4"/> : <FileText className="h-4 w-4"/>}
|
||||
</div>
|
||||
<div className="flex-1 font-medium truncate text-white">{displayName}</div>
|
||||
<div className="w-28 text-right text-sm text-white/60">{isDir ? 'folder' : 'file'}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: Preview */}
|
||||
<div className="lg:col-span-2 bg-white/5 border border-white/10 rounded-lg">
|
||||
<div className="border-b border-white/10 px-4 py-3 text-sm font-semibold flex items-center justify-between">
|
||||
<span>Preview</span>
|
||||
{selectedFile && <span className="text-xs text-white/60 truncate ml-2">{selectedFile}</span>}
|
||||
</div>
|
||||
{previewLoading ? (
|
||||
<div className="px-4 py-6 text-white/60">Loading file...</div>
|
||||
) : !selectedFile ? (
|
||||
<div className="p-6 text-white/60">Select a file to preview its contents.</div>
|
||||
) : previewContent ? (
|
||||
<pre className="p-4 whitespace-pre-wrap text-sm text-white/90 overflow-auto">{previewContent}</pre>
|
||||
) : (
|
||||
<div className="p-6 text-white/60">No preview available (binary or empty file).</div>
|
||||
<Card className="bg-white/5 border-white/10 overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{loading && (
|
||||
<div className="px-4 py-6 text-white/60">Loading...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!loading && visible.length === 0 && (
|
||||
<div className="px-4 py-6 text-white/60">No entries found.</div>
|
||||
)}
|
||||
{visible.map((e, i) => (
|
||||
<div key={i} className="flex items-center px-4 py-3 border-b border-white/10 hover:bg-white/5 cursor-pointer"
|
||||
onClick={() => e.type === 'directory' ? navigateFolder(e.name) : void 0}>
|
||||
<div className="w-7 flex justify-center">
|
||||
{e.type === 'directory' ? <Folder className="h-4 w-4"/> : <FileText className="h-4 w-4"/>}
|
||||
</div>
|
||||
<div className="flex-1 font-medium truncate">{e.name}</div>
|
||||
<div className="w-40 text-right text-sm text-white/60 flex items-center justify-end gap-1">
|
||||
<Clock className="h-4 w-4"/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white/5 border-white/10">
|
||||
<CardContent className="p-0">
|
||||
|
||||
@ -5,7 +5,8 @@ import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { MainDashboard } from "@/components/main-dashboard"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
|
||||
export default function ProjectBuilderPage() {
|
||||
// Component that uses useSearchParams - needs to be wrapped in Suspense
|
||||
function ProjectBuilderContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { user, isLoading } = useAuth()
|
||||
@ -52,9 +53,13 @@ export default function ProjectBuilderPage() {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
}
|
||||
|
||||
return <MainDashboard />
|
||||
}
|
||||
|
||||
export default function ProjectBuilderPage() {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<MainDashboard />
|
||||
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
|
||||
<ProjectBuilderContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user