studio ide prototype init

This commit is contained in:
Francesco Sansalvadore committed 2026-03-19 16:17:32 +01:00
1 parent b881a71ce7
commit a44e15ff6e
79 files changed
+2107 -20

No files matched your search

@@ -0,0 +1,10 @@
import { redirect } from 'next/navigation'
export default async function V2ProjectShortcutPage({
params,
}: {
params: Promise<{ projectRef: string }>
}) {
const { projectRef } = await params
redirect(`/dashboard/v2/project/${projectRef}`)
}
@@ -0,0 +1,58 @@
'use client'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { V2OrgParamsProvider } from '@/app/v2/V2ParamsContext'
function OrgDashboardContent() {
const params = useParams()
const orgSlug = params?.orgSlug as string | undefined
const { data: org } = useOrganizationsQuery({
select: (orgs) => orgs?.find((o) => o.slug === orgSlug),
})
const { data: projectsData, isPending } = useOrgProjectsInfiniteQuery(
{ slug: orgSlug, limit: 50 },
{ enabled: Boolean(orgSlug) }
)
const projects = projectsData?.pages?.flatMap((p) => p.projects) ?? []
if (!orgSlug) {
return <p className="p-6 text-muted-foreground text-sm">Select an organization.</p>
}
return (
<div className="p-6">
<h1 className="text-lg font-semibold text-foreground mb-2">{org?.name ?? orgSlug}</h1>
<p className="text-sm text-muted-foreground mb-4">Projects</p>
{isPending ? (
<ShimmeringLoader className="h-10 w-full rounded" />
) : (
<ul className="space-y-2">
{projects.map((proj) => (
<li key={proj.ref}>
<Link
href={`/dashboard/v2/project/${proj.ref}`}
className="text-sm text-foreground hover:underline"
>
{proj.name}
</Link>
</li>
))}
</ul>
)}
</div>
)
}
export default function V2OrgPage() {
return (
<V2OrgParamsProvider>
<OrgDashboardContent />
</V2OrgParamsProvider>
)
}
@@ -0,0 +1,32 @@
'use client'
import { useParams } from 'next/navigation'
import { useMemo } from 'react'
import { V2ParamsProvider } from '@/app/v2/V2ParamsContext'
import { Shell } from '@/components/v2/Shell'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
export function ProjectShell({ children }: { children: React.ReactNode }) {
const params = useParams()
const projectRef = params?.projectRef as string | undefined
const { data: project } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: Boolean(projectRef) }
)
const { data: organizations } = useOrganizationsQuery()
const orgSlug = useMemo(() => {
if (!project?.organization_id || !organizations) return undefined
const org = organizations.find((o) => o.id === project.organization_id)
return org?.slug
}, [project?.organization_id, organizations])
return (
<V2ParamsProvider projectRef={projectRef} orgSlug={orgSlug}>
<Shell>{children}</Shell>
</V2ParamsProvider>
)
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataBucketsPage() {
return <V2PlaceholderView title="Storage buckets" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataChannelsPage() {
return <V2PlaceholderView title="Realtime channels" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataEdgeFunctionsPage() {
return <V2PlaceholderView title="Edge functions" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataExtensionsPage() {
return <V2PlaceholderView title="Extensions" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataFunctionsPage() {
return <V2PlaceholderView title="Database functions" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataIndexesPage() {
return <V2PlaceholderView title="Indexes" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataOAuthAppsPage() {
return <V2PlaceholderView title="OAuth apps" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataProvidersPage() {
return <V2PlaceholderView title="Auth providers" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataPublicationsPage() {
return <V2PlaceholderView title="Publications" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataRolesPage() {
return <V2PlaceholderView title="Roles" />
}
@@ -0,0 +1,5 @@
import { V2TableDetailView } from '@/components/v2/views/V2TableDetailView'
export default function TableDataPage() {
return <V2TableDetailView subTab="data" />
}
@@ -0,0 +1,5 @@
import { V2TableDetailView } from '@/components/v2/views/V2TableDetailView'
export default function TableIndexesPage() {
return <V2TableDetailView subTab="indexes" />
}
@@ -0,0 +1,54 @@
'use client'
import Link from 'next/link'
import { useParams, usePathname } from 'next/navigation'
import { cn } from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
const SUB_TABS = [
{ slug: 'data', label: 'Data' },
{ slug: 'schema', label: 'Schema' },
{ slug: 'policies', label: 'Policies' },
{ slug: 'indexes', label: 'Indexes' },
{ slug: 'settings', label: 'Settings' },
]
export default function TableDetailLayout({
children,
}: {
children: React.ReactNode
}) {
const params = useParams()
const pathname = usePathname()
const { projectRef } = useV2Params()
const tableId = params?.tableId as string
const base = projectRef ? `/dashboard/v2/project/${projectRef}/data/tables/${tableId}` : ''
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-1 px-4 py-2 border-b border-border shrink-0">
{SUB_TABS.map((tab) => {
const href = `${base}/${tab.slug}`
const isActive = pathname === href || pathname?.startsWith(href + '/')
return (
<Link
key={tab.slug}
href={href}
className={cn(
'px-3 py-1.5 text-xs rounded',
isActive
? 'bg-sidebar-accent text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
>
{tab.label}
</Link>
)
})}
</div>
<div className="flex-1 min-h-0 overflow-auto">{children}</div>
</div>
)
}
@@ -0,0 +1,10 @@
import { redirect } from 'next/navigation'
export default async function TableDetailPage({
params,
}: {
params: Promise<{ projectRef: string; tableId: string }>
}) {
const { projectRef, tableId } = await params
redirect(`/dashboard/v2/project/${projectRef}/data/tables/${tableId}/data`)
}
@@ -0,0 +1,5 @@
import { V2TableDetailView } from '@/components/v2/views/V2TableDetailView'
export default function TablePoliciesPage() {
return <V2TableDetailView subTab="policies" />
}
@@ -0,0 +1,5 @@
import { V2TableDetailView } from '@/components/v2/views/V2TableDetailView'
export default function TableSchemaPage() {
return <V2TableDetailView subTab="schema" />
}
@@ -0,0 +1,5 @@
import { V2TableDetailView } from '@/components/v2/views/V2TableDetailView'
export default function TableSettingsPage() {
return <V2TableDetailView subTab="settings" />
}
@@ -0,0 +1,5 @@
import { V2TablesList } from '@/components/v2/views/V2TablesList'
export default function DataTablesPage() {
return <V2TablesList />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataTypesPage() {
return <V2PlaceholderView title="Enumerated types" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataUsersPage() {
return <V2PlaceholderView title="Auth users" />
}
@@ -0,0 +1,9 @@
import { ProjectShell } from './ProjectShell'
export default function ProjectLayout({
children,
}: {
children: React.ReactNode
}) {
return <ProjectShell>{children}</ProjectShell>
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function ObsAlertsPage() {
return <V2PlaceholderView title="Active alerts" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function ObsLogsApiPage() {
return <V2PlaceholderView title="API gateway logs" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function ObsLogsAuthPage() {
return <V2PlaceholderView title="Auth logs" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function ObsLogsPage() {
return <V2PlaceholderView title="All logs" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function ObsLogsPostgresPage() {
return <V2PlaceholderView title="Postgres logs" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function ObsMetricsConnectionsPage() {
return <V2PlaceholderView title="Connections metric" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function ObsMetricsCpuPage() {
return <V2PlaceholderView title="CPU metric" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function ObsMetricsDiskPage() {
return <V2PlaceholderView title="Disk metric" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function ObsMetricsMemoryPage() {
return <V2PlaceholderView title="Memory metric" />
}
@@ -0,0 +1,10 @@
import { redirect } from 'next/navigation'
export default async function V2ProjectPage({
params,
}: {
params: Promise<{ projectRef: string }>
}) {
const { projectRef } = await params
redirect(`/dashboard/v2/project/${projectRef}/data/tables`)
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function SettingsApiKeysPage() {
return <V2PlaceholderView title="API keys" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function SettingsAuthPage() {
return <V2PlaceholderView title="Auth config" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function SettingsBillingPage() {
return <V2PlaceholderView title="Billing" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function SettingsBranchesPage() {
return <V2PlaceholderView title="Branches" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function SettingsComputePage() {
return <V2PlaceholderView title="Compute & disk" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function SettingsFunctionsPage() {
return <V2PlaceholderView title="Functions config" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function SettingsGeneralPage() {
return <V2PlaceholderView title="General settings" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function SettingsMembersPage() {
return <V2PlaceholderView title="Team members" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function SettingsMergeRequestsPage() {
return <V2PlaceholderView title="Merge requests" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function SettingsNetworkPage() {
return <V2PlaceholderView title="Network" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function SettingsRealtimePage() {
return <V2PlaceholderView title="Realtime config" />
}
@@ -0,0 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function SettingsStoragePage() {
return <V2PlaceholderView title="Storage config" />
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from 'next'
import { AppRouterProviders } from '@/app/v2/AppRouterProviders'
import { genFaviconData } from 'common/MetaFavicons/app-router'
import 'react-data-grid/lib/styles.css'
import 'styles/code.scss'
import 'styles/contextMenu.scss'
import 'styles/editor.scss'
import 'styles/focus.scss'
import 'styles/graphiql-base.scss'
import 'styles/grid.scss'
import 'styles/main.scss'
import 'styles/markdown-preview.scss'
import 'styles/monaco.scss'
import 'styles/react-data-grid-logs.scss'
import 'styles/reactflow.scss'
import 'styles/storage.scss'
import 'styles/stripe.scss'
import 'styles/toast.scss'
import 'styles/typography.scss'
import 'styles/ui.scss'
import 'ui-patterns/ShimmeringLoader/index.css'
import 'ui/build/css/themes/dark.css'
import 'ui/build/css/themes/light.css'
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? ''
export const metadata: Metadata = {
applicationName: 'Supabase Studio',
title: 'Supabase',
icons: genFaviconData(BASE_PATH),
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<AppRouterProviders>{children}</AppRouterProviders>
</body>
</html>
)
}
+35
View File
@@ -0,0 +1,35 @@
'use client'
import { QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider } from 'common'
import { useRootQueryClient } from 'data/query-client'
import { customFont, sourceCodePro } from 'fonts'
import { AuthProvider } from 'lib/auth'
import { ReactNode } from 'react'
import { TooltipProvider } from 'ui'
export function AppRouterProviders({ children }: { children: ReactNode }) {
const queryClient = useRootQueryClient()
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<style
dangerouslySetInnerHTML={{
__html: `:root{--font-custom:${customFont.style.fontFamily};--font-source-code-pro:${sourceCodePro.style.fontFamily};}`,
}}
/>
<TooltipProvider delayDuration={0}>
<ThemeProvider
defaultTheme="system"
themes={['dark', 'light', 'classic-dark']}
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</TooltipProvider>
</AuthProvider>
</QueryClientProvider>
)
}
+51
View File
@@ -0,0 +1,51 @@
'use client'
import { useParams } from 'next/navigation'
import { createContext, useContext, type ReactNode } from 'react'
export interface V2Params {
orgSlug: string | undefined
projectRef: string | undefined
}
const V2ParamsContext = createContext<V2Params>({
orgSlug: undefined,
projectRef: undefined,
})
/** Use in project scope: params have projectRef; orgSlug is passed in after being derived from project. */
export function V2ParamsProvider({
children,
projectRef,
orgSlug,
}: {
children: ReactNode
projectRef: string | undefined
orgSlug: string | undefined
}) {
return (
<V2ParamsContext.Provider value={{ orgSlug, projectRef }}>
{children}
</V2ParamsContext.Provider>
)
}
/** Use in org scope: params have orgSlug only. */
export function V2OrgParamsProvider({ children }: { children: ReactNode }) {
const params = useParams()
const orgSlug = params?.orgSlug as string | undefined
return (
<V2ParamsContext.Provider value={{ orgSlug, projectRef: undefined }}>
{children}
</V2ParamsContext.Provider>
)
}
export function useV2Params(): V2Params {
const ctx = useContext(V2ParamsContext)
if (!ctx) {
return { orgSlug: undefined, projectRef: undefined }
}
return ctx
}
+3
View File
@@ -0,0 +1,3 @@
export default function V2Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>
}
@@ -3,7 +3,7 @@ import { useChat } from '@ai-sdk/react'
import { lastAssistantMessageIsCompleteWithToolCalls } from 'ai'
import { AnimatePresence, motion } from 'framer-motion'
import { Eraser, Pencil, X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useRouter } from 'next/compat/router'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
@@ -117,7 +117,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
const { mutateAsync: rateMessage } = useRateMessageMutation()
const isInSQLEditor = router.pathname.includes('/sql/[id]')
const isInSQLEditor = router?.pathname?.includes('/sql/') ?? false
const snippet = snippets[entityId ?? '']
const snippetContent = snippet?.snippet?.content?.sql
@@ -1,6 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/router'
import { useRouter } from 'next/compat/router'
import { useRef, useState, type DragEvent, type PropsWithChildren } from 'react'
import { useParams } from 'common'
@@ -90,8 +90,8 @@ export const DisplayBlockRenderer = ({
const [rows, setRows] = useState<any[] | undefined>(
Array.isArray(initialResults) ? initialResults : undefined
)
const isReportsPage = router.pathname.endsWith('/reports/[id]')
const isHomePage = router.pathname === '/project/[ref]'
const isReportsPage = router?.pathname?.includes('/reports/') ?? false
const isHomePage = router?.pathname?.includes('/project/') ?? false
const isDraggableToReports = canCreateSQLSnippet && (isReportsPage || isHomePage)
const label = initialArgs.label || 'SQL Results'
const [isWriteQuery, setIsWriteQuery] = useState<boolean>(initialArgs.isWriteQuery || false)
@@ -1,7 +1,7 @@
import { Check, ChevronsUpDown } from 'lucide-react'
import { useState } from 'react'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useRouter } from 'next/router'
import { useRouter } from 'next/compat/router'
import {
Badge,
Button,
@@ -2,7 +2,7 @@ import type { SupportFormUrlKeys } from 'components/interfaces/Support/SupportFo
import { SupportLink } from 'components/interfaces/Support/SupportLink'
import { DOCS_URL } from 'lib/constants'
import { Activity, BookOpen, Mail, Wrench } from 'lucide-react'
import { useRouter } from 'next/router'
import { useRouter } from 'next/compat/router'
import SVG from 'react-inlinesvg'
import { AiIconAnimation, ButtonGroup, ButtonGroupItem } from 'ui'
@@ -32,7 +32,7 @@ export const HelpOptionsList = ({
size = 'tiny',
}: HelpOptionsListProps) => {
const router = useRouter()
const basePath = router.basePath ?? ''
const basePath = router?.basePath ?? ''
const ids = HELP_OPTION_IDS.filter((id) => !excludeIds.includes(id))
@@ -4,7 +4,7 @@ import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/Lay
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { X } from 'lucide-react'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { useRouter } from 'next/compat/router'
import SVG from 'react-inlinesvg'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
@@ -26,6 +26,7 @@ export const HelpPanel = ({
const snap = useAiAssistantStateSnapshot()
const { openSidebar, closeSidebar } = useSidebarManagerSnapshot()
const router = useRouter()
const basePath = router?.basePath ?? ''
const __styles = styleHandler('popover')
@@ -76,7 +77,7 @@ export const HelpPanel = ({
>
<Image
className="absolute left-0 top-0 opacity-50 transition-opacity group-hover:opacity-40"
src={`${router.basePath}/img/support/discord-bg-small.jpg`}
src={`${basePath}/img/support/discord-bg-small.jpg`}
layout="fill"
objectFit="cover"
alt="Discord illustration"
@@ -84,7 +85,7 @@ export const HelpPanel = ({
<Button
type="secondary"
size="tiny"
icon={<SVG src={`${router.basePath}/img/discord-icon.svg`} className="h-4 w-4" />}
icon={<SVG src={`${basePath}/img/discord-icon.svg`} className="h-4 w-4" />}
>
<span style={{ color: '#404EED' }}>Join us on Discord</span>
</Button>
+145
View File
@@ -0,0 +1,145 @@
'use client'
import { useProjectLintsQuery } from 'data/lint/lint-query'
import { Database, LayoutDashboard, Settings } from 'lucide-react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
export type ActivityId = 'data' | 'obs' | 'settings'
interface ActivityBarProps {
side: 'left' | 'right'
activeId: string | null
onSelect?: (id: string) => void
items: Array<{
id: string
icon: React.ReactNode
label: string
badge?: boolean
href?: string
}>
/** For right bar, tooltips show on the left of the icon */
tooltipSide?: 'left' | 'right'
bottomContent?: React.ReactNode
}
export function ActivityBar({
side,
activeId,
onSelect,
items,
tooltipSide = 'right',
bottomContent,
}: ActivityBarProps) {
return (
<aside
className={cn(
'w-11 flex flex-col shrink-0 border-border bg-background',
side === 'left' && 'border-r',
side === 'right' && 'border-l'
)}
>
<div className="flex flex-col flex-1 gap-1 p-1.5">
{items.map((item) => {
const isActive = activeId === item.id
const content = (
<Tooltip key={item.id} delayDuration={0}>
<TooltipTrigger asChild>
{item.href ? (
<Link
href={item.href}
className={cn(
'relative flex items-center justify-center w-full aspect-square rounded-lg shrink-0 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent',
isActive && 'bg-sidebar-accent text-foreground',
side === 'left' && isActive && 'border-1 border-foreground',
side === 'right' && isActive && 'border-1 border-foreground'
)}
>
<span className="relative">
{item.icon}
{item.badge && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-amber-500" />
)}
</span>
</Link>
) : (
<button
type="button"
onClick={() => onSelect?.(item.id)}
className={cn(
'relative flex items-center justify-center w-full aspect-square rounded-lg shrink-0 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent',
isActive && 'bg-sidebar-accent text-foreground',
side === 'left' && isActive && 'border-l-2 border-l-foreground',
side === 'right' && isActive && 'border-r-2 border-r-foreground'
)}
>
<span className="relative">
{item.icon}
{item.badge && (
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-amber-500" />
)}
</span>
</button>
)}
</TooltipTrigger>
<TooltipContent side={tooltipSide} className="text-xs">
{item.label}
</TooltipContent>
</Tooltip>
)
return content
})}
</div>
{bottomContent && <div className="mt-auto">{bottomContent}</div>}
</aside>
)
}
export function LeftActivityBar() {
const pathname = usePathname()
const { projectRef } = useV2Params()
const activeId: ActivityId | null = pathname?.includes('/data/')
? 'data'
: pathname?.includes('/obs/')
? 'obs'
: pathname?.includes('/settings/')
? 'settings'
: null
const { data: lints } = useProjectLintsQuery({ projectRef })
const hasAdvisorWarnings = (lints?.length ?? 0) > 0
const base = projectRef ? `/dashboard/v2/project/${projectRef}` : '#'
return (
<ActivityBar
side="left"
activeId={activeId}
items={[
{
id: 'data',
icon: <Database className="h-4 w-4" strokeWidth={1.5} />,
label: 'Data',
href: `${base}/data/tables`,
},
{
id: 'obs',
icon: <LayoutDashboard className="h-4 w-4" strokeWidth={1.5} />,
label: 'Observability',
badge: hasAdvisorWarnings,
href: `${base}/obs/logs`,
},
{
id: 'settings',
icon: <Settings className="h-4 w-4" strokeWidth={1.5} />,
label: 'Settings',
href: `${base}/settings/general`,
},
]}
tooltipSide="right"
/>
)
}
@@ -0,0 +1,53 @@
'use client'
import Link from 'next/link'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { useProjectLintsQuery } from 'data/lint/lint-query'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
export function AdvisorsPanel() {
const { projectRef } = useV2Params()
const { data: lints, isPending, isError } = useProjectLintsQuery({ projectRef })
if (isPending) return <ShimmeringLoader className="m-3 h-8" />
if (isError) return <p className="p-3 text-sm text-destructive">Failed to load advisors.</p>
const list = lints ?? []
if (list.length === 0) {
return (
<div className="p-3 text-sm text-muted-foreground">
No advisor warnings. Your project looks good.
</div>
)
}
return (
<div className="p-2 space-y-1">
{list.slice(0, 20).map((lint, i) => (
<Link
key={lint.cache_key ?? i}
href={
projectRef
? `/project/${projectRef}/advisors/security?preset=${lint.level}&id=${lint.cache_key}`
: '#'
}
className="block p-2 rounded border border-border hover:bg-sidebar-accent text-left"
>
<div className="text-xs font-medium text-foreground">{lint.name}</div>
<div className="text-xs text-muted-foreground truncate">{lint.description}</div>
<span
className={`inline-block mt-1 text-[10px] px-1.5 py-0 rounded ${
lint.level === 'ERROR'
? 'bg-destructive/20 text-destructive'
: lint.level === 'WARN'
? 'bg-amber-500/20 text-amber-600 dark:text-amber-400'
: 'bg-muted text-muted-foreground'
}`}
>
{lint.level}
</span>
</Link>
))}
</div>
)
}
@@ -0,0 +1,32 @@
'use client'
import { usePathname } from 'next/navigation'
import { useState } from 'react'
import { Button, Input } from 'ui'
export function AssistantPanel() {
const pathname = usePathname()
const viewName = pathname?.split('/').filter(Boolean).slice(-2).join(' / ') ?? 'Dashboard'
const [input, setInput] = useState('')
return (
<div className="flex flex-col h-full p-3">
<p className="text-xs text-muted-foreground mb-3">Context: {viewName}</p>
<div className="flex-1 rounded border border-border bg-muted/30 p-3 mb-3 text-sm text-muted-foreground">
<p>Welcome. I can help you with your database, auth, storage, and more.</p>
<p className="mt-2">Ask a question or describe what you want to do.</p>
</div>
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask anything..."
className="text-sm flex-1"
/>
<Button type="default" size="small">
Send
</Button>
</div>
</div>
)
}
+257
View File
@@ -0,0 +1,257 @@
'use client'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from 'ui'
import { useV2DataCounts } from './useV2DataCounts'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { useV2DashboardStore } from '@/stores/v2-dashboard'
const DATA_GROUPS = [
{
id: 'data-database',
label: 'Database',
items: [
{ href: 'tables', label: 'Tables', countKey: 'tables' },
{ href: 'functions', label: 'Functions', countKey: 'functions' },
{ href: 'types', label: 'Enumerated types', countKey: 'types' },
{ href: 'roles', label: 'Roles', countKey: 'roles' },
{ href: 'extensions', label: 'Extensions', countKey: 'extensions' },
{ href: 'indexes', label: 'Indexes', countKey: 'indexes' },
{ href: 'publications', label: 'Publications', countKey: 'publications' },
],
},
{
id: 'data-auth',
label: 'Auth',
items: [
{ href: 'users', label: 'Users', countKey: 'users' },
{ href: 'providers', label: 'Providers', countKey: 'providers' },
{ href: 'oauth-apps', label: 'OAuth apps', countKey: 'oauthApps' },
],
},
{
id: 'data-storage',
label: 'Storage',
items: [{ href: 'buckets', label: 'Buckets', countKey: 'buckets' }],
},
{
id: 'data-edge-functions',
label: 'Edge functions',
items: [{ href: 'edge-functions', label: 'Functions', countKey: 'edgeFunctions' }],
},
{
id: 'data-realtime',
label: 'Realtime',
items: [{ href: 'channels', label: 'Channels', countKey: 'channels' }],
},
]
const OBS_GROUPS = [
{
id: 'obs-logs',
label: 'Log streams',
items: [
{ href: 'logs', label: 'All logs' },
{ href: 'logs/api', label: 'API gateway' },
{ href: 'logs/postgres', label: 'Postgres' },
{ href: 'logs/auth', label: 'Auth' },
],
},
{
id: 'obs-metrics',
label: 'Metrics',
items: [
{ href: 'metrics/connections', label: 'Connections' },
{ href: 'metrics/cpu', label: 'CPU' },
{ href: 'metrics/memory', label: 'Memory' },
{ href: 'metrics/disk', label: 'Disk' },
],
},
{
id: 'obs-alerts',
label: 'Alerts',
items: [{ href: 'alerts', label: 'Active alerts' }],
},
]
const SETTINGS_GROUPS = [
{
id: 'settings-project',
label: 'Project',
items: [
{ href: 'general', label: 'General' },
{ href: 'compute', label: 'Compute & disk' },
{ href: 'network', label: 'Network' },
{ href: 'api-keys', label: 'API keys' },
],
},
{
id: 'settings-branches',
label: 'Branches',
items: [
{ href: 'branches', label: 'Branches' },
{ href: 'merge-requests', label: 'Merge requests' },
],
},
{
id: 'settings-modules',
label: 'Modules',
items: [
{ href: 'auth', label: 'Auth config' },
{ href: 'storage', label: 'Storage config' },
{ href: 'functions', label: 'Functions config' },
{ href: 'realtime', label: 'Realtime config' },
],
},
{
id: 'settings-org',
label: 'Organization',
items: [
{ href: 'members', label: 'Members' },
{ href: 'billing', label: 'Billing' },
],
},
]
type GroupStatus = 'green' | 'yellow' | 'red'
function getMockGroupStatus(groupId: string): GroupStatus {
const statuses: GroupStatus[] = ['green', 'yellow', 'red']
const hash = groupId.split('').reduce((acc, ch) => acc + ch.charCodeAt(0), 0)
return statuses[hash % statuses.length]
}
function getStatusClass(status: GroupStatus): string {
switch (status) {
case 'green':
return 'bg-emerald-500'
case 'yellow':
return 'bg-amber-500'
case 'red':
return 'bg-red-500'
default:
return 'bg-muted-foreground'
}
}
function getCount(counts: ReturnType<typeof useV2DataCounts>, key: string): number | string {
switch (key) {
case 'tables':
return counts.tables
case 'functions':
return counts.functions
case 'types':
return counts.types
case 'roles':
return counts.roles
case 'extensions':
return counts.extensions
case 'indexes':
return '—'
case 'publications':
return counts.publications
case 'users':
return counts.users
case 'providers':
case 'oauthApps':
case 'channels':
return '—'
case 'buckets':
return counts.buckets
case 'edgeFunctions':
return counts.edgeFunctions
default:
return 0
}
}
export function BrowserPanel() {
const pathname = usePathname()
const { orgSlug, projectRef } = useV2Params()
const { expandedGroups, toggleGroup } = useV2DashboardStore()
const counts = useV2DataCounts(projectRef)
const base = projectRef ? `/dashboard/v2/project/${projectRef}` : ''
const isData = pathname?.includes('/data/')
const isObs = pathname?.includes('/obs/')
const isSettings = pathname?.includes('/settings/')
const title = isData ? 'Data' : isObs ? 'Observability' : isSettings ? 'Settings' : ''
const groups = isData ? DATA_GROUPS : isObs ? OBS_GROUPS : isSettings ? SETTINGS_GROUPS : []
return (
<div className="w-full h-full flex flex-col border-r border-border bg-background">
<div className="px-3 py-2 border-b border-border text-sm font-medium text-foreground">
{title}
</div>
<div className="flex-1 overflow-auto py-1">
{groups.map((group) => {
const isExpanded = expandedGroups[group.id] !== false
const status = getMockGroupStatus(group.id)
return (
<div key={group.id} className="py-0.5">
<button
type="button"
onClick={() => toggleGroup(group.id)}
className="flex items-center justify-between w-full px-3 py-1.5 text-left font-mono uppercase text-xs text-foreground-lighter hover:bg-sidebar-accent gap-2"
>
<span className="truncate">{group.label}</span>
<span className="flex items-center gap-1.5 shrink-0">
<span
className={cn('h-1.5 w-1.5 rounded-full', getStatusClass(status))}
aria-label={`${status} status`}
/>
<ChevronRight
className={cn(
'h-4 w-4 shrink-0 transition-transform',
isExpanded && 'rotate-90'
)}
/>
</span>
</button>
{isExpanded &&
group.items.map((item) => {
const itemHref = isData
? `${base}/data/${item.href}`
: isObs
? `${base}/obs/${item.href}`
: `${base}/settings/${item.href}`
const isActive = pathname === itemHref || pathname?.startsWith(itemHref + '/')
return (
<Link
key={item.href}
href={itemHref}
className={cn(
'flex items-center justify-between pl-8 pr-4 py-1.5 text-sm',
isActive
? 'bg-sidebar-accent text-foreground font-medium'
: 'text-foreground-light hover:text-foreground hover:bg-sidebar-accent/50'
)}
>
<span className="truncate">{item.label}</span>
{'countKey' in item && (
<span className="text-xs text-muted-foreground shrink-0">
{getCount(counts, String(item.countKey))}
</span>
)}
</Link>
)
})}
</div>
)
})}
{isData && (
<div className="px-3 py-2 mt-2">
<Link href="#" className="text-xs text-muted-foreground hover:text-foreground">
+ Add module
</Link>
</div>
)}
</div>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
'use client'
import { usePathname } from 'next/navigation'
import { cn } from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { TabBar } from './TabBar'
import { StaticTitle } from './StaticTitle'
import { V2EditorFooter } from './V2EditorFooter'
export function EditorFrame({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const { projectRef } = useV2Params()
const isDataCategory =
pathname?.includes('/data/tables') ||
pathname?.includes('/data/functions') ||
pathname?.includes('/data/types') ||
pathname?.includes('/data/roles') ||
pathname?.includes('/data/extensions') ||
pathname?.includes('/data/indexes') ||
pathname?.includes('/data/publications') ||
pathname?.includes('/data/users') ||
pathname?.includes('/data/providers') ||
pathname?.includes('/data/oauth-apps') ||
pathname?.includes('/data/buckets') ||
pathname?.includes('/data/edge-functions') ||
pathname?.includes('/data/channels')
return (
<div className="flex flex-col flex-1 min-w-0 bg-background">
{isDataCategory ? (
<TabBar />
) : (
<StaticTitle />
)}
<div className="flex-1 flex flex-col min-h-0 overflow-auto">
{children}
</div>
<V2EditorFooter />
</div>
)
}
@@ -0,0 +1,27 @@
'use client'
import { AdvisorButton } from 'components/layouts/AppLayout/AdvisorButton'
import { AssistantButton } from 'components/layouts/AppLayout/AssistantButton'
import { InlineEditorButton } from 'components/layouts/AppLayout/InlineEditorButton'
import { HelpButton } from 'components/ui/HelpPanel/HelpButton'
import { useV2Params } from '@/app/v2/V2ParamsContext'
export function RightActivityBar() {
const { projectRef } = useV2Params()
return (
<aside className="w-11 flex flex-col shrink-0 border-l border-border bg-background py-2 h-full">
<div className="flex flex-col items-center gap-2">
<AssistantButton />
<InlineEditorButton />
<AdvisorButton projectRef={projectRef} />
</div>
<div className="flex-1" />
<div className="flex flex-col items-center">
<HelpButton />
</div>
</aside>
)
}
+43
View File
@@ -0,0 +1,43 @@
'use client'
import { usePathname } from 'next/navigation'
import { X } from 'lucide-react'
import { Button, cn } from 'ui'
import { useV2DashboardStore } from '@/stores/v2-dashboard'
import { AssistantPanel } from './AssistantPanel'
import { SqlPanel } from './SqlPanel'
import { AdvisorsPanel } from './AdvisorsPanel'
export function RightPanel() {
const rightPanel = useV2DashboardStore((s) => s.rightPanel)
const closeRightPanel = useV2DashboardStore((s) => s.closeRightPanel)
if (!rightPanel) return null
const titles = { chat: 'Assistant', sql: 'SQL', adv: 'Advisors' }
const title = titles[rightPanel]
return (
<div className="w-[270px] flex flex-col shrink-0 border-l border-border bg-background">
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
<span className="text-sm font-medium text-foreground">{title}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={closeRightPanel}
aria-label="Close panel"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 min-h-0 overflow-auto">
{rightPanel === 'chat' && <AssistantPanel />}
{rightPanel === 'sql' && <SqlPanel />}
{rightPanel === 'adv' && <AdvisorsPanel />}
</div>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
'use client'
import { MobileSheetProvider } from 'components/layouts/Navigation/NavigationBar/MobileSheetContext'
import { LayoutSidebar } from 'components/layouts/ProjectLayout/LayoutSidebar'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup, SidebarProvider } from 'ui'
import { LeftActivityBar } from './ActivityBar'
import { BrowserPanel } from './BrowserPanel'
import { EditorFrame } from './EditorFrame'
import { RightActivityBar } from './RightActivityBar'
import { TopBar } from './TopBar'
import { V2LayoutSidebarProvider } from './V2LayoutSidebarProvider'
import { V2DashboardProvider } from '@/stores/v2-dashboard'
export function Shell({ children }: { children: React.ReactNode }) {
return (
<V2DashboardProvider>
<SidebarProvider defaultOpen={false}>
<MobileSheetProvider>
<V2LayoutSidebarProvider>
<div className="flex flex-col h-screen w-screen bg-background text-foreground">
<TopBar />
<div className="flex flex-1 min-h-0">
<LeftActivityBar />
<ResizablePanelGroup
orientation="horizontal"
className="h-full w-full overflow-x-hidden flex-1 flex flex-row gap-0"
autoSaveId="v2-shell-content"
>
<ResizablePanel id="panel-browser" minSize={200} maxSize={350} defaultSize={240}>
<BrowserPanel />
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel id="panel-content">
<main className="flex-1 min-w-0 flex flex-col overflow-hidden h-full">
<EditorFrame>{children}</EditorFrame>
</main>
</ResizablePanel>
<LayoutSidebar minSize={350} maxSize={500} defaultSize={350} />
</ResizablePanelGroup>
<RightActivityBar />
</div>
</div>
</V2LayoutSidebarProvider>
</MobileSheetProvider>
</SidebarProvider>
</V2DashboardProvider>
)
}
+58
View File
@@ -0,0 +1,58 @@
'use client'
import { useState } from 'react'
import { Button } from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
export function SqlPanel() {
const { projectRef } = useV2Params()
const [sql, setSql] = useState('SELECT 1')
const { data: project } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: Boolean(projectRef) }
)
const { mutate: executeSql, isPending, data, error } = useExecuteSqlMutation()
const handleRun = () => {
if (!projectRef || !project?.connectionString) return
executeSql({
projectRef,
connectionString: project.connectionString,
sql,
})
}
return (
<div className="flex flex-col h-full p-3">
<textarea
value={sql}
onChange={(e) => setSql(e.target.value)}
placeholder="SELECT 1"
className="flex-1 min-h-[120px] w-full rounded border border-border bg-background p-2 text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-ring"
spellCheck={false}
/>
<Button
type="primary"
size="small"
className="mt-2"
onClick={handleRun}
disabled={isPending || !projectRef}
>
{isPending ? 'Running…' : 'Run'}
</Button>
{error && (
<pre className="mt-2 p-2 text-xs text-destructive bg-destructive/10 rounded overflow-auto">
{error.message}
</pre>
)}
{data?.result && (
<pre className="mt-2 p-2 text-xs text-muted-foreground bg-muted/30 rounded overflow-auto max-h-40">
{JSON.stringify(data.result, null, 2)}
</pre>
)}
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
'use client'
import { usePathname } from 'next/navigation'
export function StaticTitle() {
const pathname = usePathname()
const segment = pathname?.split('/').filter(Boolean).slice(-2) ?? []
const title = segment[segment.length - 1] ?? 'Overview'
const formatted = title.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
return (
<div className="px-4 py-2 border-b border-border bg-background shrink-0">
<h1 className="text-sm font-medium text-foreground">{formatted}</h1>
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { List, Plus, X } from 'lucide-react'
import { cn } from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { useV2DashboardStore } from '@/stores/v2-dashboard'
const CATEGORY_LABELS: Record<string, string> = {
tables: 'Tables',
functions: 'Functions',
types: 'Enumerated types',
roles: 'Roles',
extensions: 'Extensions',
indexes: 'Indexes',
publications: 'Publications',
users: 'Users',
providers: 'Providers',
'oauth-apps': 'OAuth apps',
buckets: 'Buckets',
'edge-functions': 'Edge functions',
channels: 'Channels',
}
export function TabBar() {
const pathname = usePathname()
const { projectRef } = useV2Params()
const { detailTabs, removeDetailTab } = useV2DashboardStore()
const base = projectRef ? `/dashboard/v2/project/${projectRef}` : ''
const pathParts = pathname?.replace(/^\/v2\/[^/]+\/[^/]+/, '').split('/').filter(Boolean) ?? []
const activity = pathParts[0]
const category = pathParts[1]
const categoryLabel = category ? CATEGORY_LABELS[category] ?? category : 'Data'
const categoryListHref = category ? `${base}/data/${category}` : `${base}/data/tables`
const activeTabPath = pathname ?? ''
return (
<div className="flex items-center border-b border-border bg-background shrink-0 min-h-[36px]">
<Link
href={categoryListHref}
className={cn(
'flex items-center gap-1.5 px-3 py-2 text-sm border-r border-border',
!pathname?.includes('/data/') && 'text-foreground',
pathname === categoryListHref
? 'bg-sidebar-accent text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
>
<List className="h-4 w-4 shrink-0" />
<span className="truncate max-w-[120px]">{categoryLabel}</span>
</Link>
<button
type="button"
className="flex items-center justify-center w-8 h-8 shrink-0 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent"
title={`New ${categoryLabel.toLowerCase().replace(/s$/, '')}`}
>
<Plus className="h-4 w-4" />
</button>
{detailTabs.map((tab) => {
const isActive = activeTabPath === tab.path
return (
<div
key={tab.id}
className={cn(
'group flex items-center gap-1 pl-2 pr-1 py-1.5 border-r border-border max-w-[180px]',
isActive ? 'bg-sidebar-accent text-foreground' : 'text-muted-foreground hover:bg-sidebar-accent/50'
)}
>
<Link href={tab.path} className="flex-1 min-w-0 truncate text-sm">
{tab.label}
</Link>
<button
type="button"
onClick={(e) => {
e.preventDefault()
removeDetailTab(tab.id)
}}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:bg-background rounded text-muted-foreground hover:text-foreground"
aria-label="Close tab"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
)
}
+48
View File
@@ -0,0 +1,48 @@
'use client'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { Button, cn } from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { V2BranchSelector } from './V2BranchSelector'
import { V2OrgProjectSelector } from './V2OrgProjectSelector'
export function TopBar() {
const { orgSlug, projectRef } = useV2Params()
const { data: org } = useOrganizationsQuery({
enabled: true,
select: (data) => data.find((o) => o.slug === orgSlug),
})
const { data: project, isPending: loadingProject } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: Boolean(projectRef) }
)
return (
<header
className={cn(
'h-[38px] flex items-center justify-between px-2 border-b border-border bg-background shrink-0'
)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<V2OrgProjectSelector />
{loadingProject || !project ? (
<ShimmeringLoader className="h-5 w-24" />
) : (
<V2BranchSelector />
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
type="default"
size="tiny"
className="text-muted-foreground border border-border rounded px-2 py-1 text-xs"
>
Search ⌘K
</Button>
</div>
</header>
)
}
@@ -0,0 +1,119 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import {
Badge,
Button,
Command_Shadcn_,
CommandGroup_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
} from 'ui'
import { GitBranch } from 'lucide-react'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { useBranchesQuery } from 'data/branches/branches-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { IS_PLATFORM } from 'lib/constants'
export function V2BranchSelector() {
const router = useRouter()
const { projectRef } = useV2Params()
const [open, setOpen] = useState(false)
const { data: project } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: Boolean(projectRef) }
)
const parentRef = project?.parent_project_ref ?? projectRef
const { data: branches } = useBranchesQuery(
{ projectRef: parentRef },
{ enabled: open && IS_PLATFORM && Boolean(parentRef) }
)
const selectedBranch = branches?.find((b) => b.project_ref === projectRef)
const branchName = selectedBranch?.name ?? 'main'
const isDefault = selectedBranch?.is_default ?? true
const handleSelectBranch = (ref: string) => {
if (!orgSlug) return
setOpen(false)
router.push(`/dashboard/v2/project/${ref}/data/tables`)
}
const handleManageBranches = () => {
if (!orgSlug || !projectRef) return
setOpen(false)
router.push(`/dashboard/v2/project/${projectRef}/settings/branches`)
}
if (!IS_PLATFORM) {
return (
<span className="text-muted-foreground text-xs flex items-center gap-1">
<GitBranch className="h-3 w-3" />
main
</span>
)
}
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
variant="outline"
size="tiny"
className="h-6 gap-1 px-2 rounded-full border-border text-xs"
>
<GitBranch className="h-3 w-3" />
<span>{branchName}</span>
<Badge
variant={isDefault ? 'default' : 'secondary'}
className="text-[10px] px-1 py-0 h-4"
>
{isDefault ? 'prod' : 'preview'}
</Badge>
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-[220px] p-0" align="start">
<Command_Shadcn_>
<CommandList_Shadcn_>
<CommandGroup_Shadcn_ heading="Branches">
{branches?.map((branch) => (
<CommandItem_Shadcn_
key={branch.id}
onSelect={() => handleSelectBranch(branch.project_ref)}
>
<GitBranch className="h-3 w-3 mr-2" />
{branch.name}
{branch.is_default && (
<Badge variant="default" className="ml-auto text-[10px]">
prod
</Badge>
)}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
<CommandGroup_Shadcn_>
<CommandItem_Shadcn_ onSelect={handleManageBranches}>
Manage branches
</CommandItem_Shadcn_>
<CommandItem_Shadcn_
onSelect={() => {
setOpen(false)
// Create branch - could open modal; for now navigate to branches
handleManageBranches()
}}
>
+ Create branch
</CommandItem_Shadcn_>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -0,0 +1,20 @@
'use client'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
export function V2EditorFooter() {
const { projectRef } = useV2Params()
const { data: project } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: Boolean(projectRef) }
)
return (
<footer className="flex items-center justify-between px-3 py-1 text-xs text-muted-foreground border-t border-border bg-background shrink-0">
<span className="font-mono truncate">{projectRef ?? '—'}</span>
<span>Connections: —</span>
<span>{project?.status === 'ACTIVE_HEALTHY' ? 'Healthy' : project?.status ?? '—'}</span>
</footer>
)
}
@@ -0,0 +1,52 @@
'use client'
import dynamic from 'next/dynamic'
import { type PropsWithChildren } from 'react'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { getSupportLinkQueryParams } from 'components/ui/HelpPanel/HelpPanel.utils'
import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
const HelpPanel = dynamic(() =>
import('components/ui/HelpPanel/HelpPanel').then((m) => m.HelpPanel)
)
const AdvisorPanel = dynamic(() =>
import('components/ui/AdvisorPanel/AdvisorPanel').then((m) => m.AdvisorPanel)
)
const AIAssistant = dynamic(() =>
import('components/ui/AIAssistantPanel/AIAssistant').then((m) => m.AIAssistant)
)
const SqlPanel = dynamic(() =>
import('components/v2/SqlPanel').then((m) => m.SqlPanel)
)
export const V2LayoutSidebarProvider = ({ children }: PropsWithChildren) => {
const { projectRef } = useV2Params()
const hasProject = Boolean(projectRef)
const { closeSidebar } = useSidebarManagerSnapshot()
const { orgSlug } = useV2Params()
const supportLinkQueryParams = getSupportLinkQueryParams(undefined, { slug: orgSlug }, projectRef)
useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => <AIAssistant />, {}, 'i', hasProject)
useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => <SqlPanel />, {}, 'e', hasProject)
useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => <AdvisorPanel />, {}, undefined, true)
useRegisterSidebar(
SIDEBAR_KEYS.HELP_PANEL,
() => (
<HelpPanel
onClose={() => closeSidebar(SIDEBAR_KEYS.HELP_PANEL)}
projectRef={projectRef}
supportLinkQueryParams={supportLinkQueryParams}
/>
),
{},
undefined,
true
)
return <>{children}</>
}
@@ -0,0 +1,101 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import {
Button,
Command_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
} from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { ChevronsUpDown } from 'lucide-react'
export function V2OrgProjectSelector() {
const router = useRouter()
const { orgSlug, projectRef } = useV2Params()
const [open, setOpen] = useState(false)
const { data: orgs } = useOrganizationsQuery()
const selectedOrg = orgs?.find((o) => o.slug === orgSlug)
const { data: project } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: Boolean(projectRef) }
)
const { data: projectsData } = useOrgProjectsInfiniteQuery(
{ slug: orgSlug, limit: 50 },
{ enabled: open && Boolean(orgSlug) }
)
const projects = projectsData?.pages?.flatMap((p) => p.projects) ?? []
const handleSelectProject = (ref: string) => {
if (!orgSlug) return
setOpen(false)
router.push(`/dashboard/v2/project/${ref}/data/tables`)
}
const handleSelectOrg = (slug: string) => {
setOpen(false)
router.push(`/dashboard/v2/org/${slug}`)
}
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
variant="ghost"
size="tiny"
className="gap-1 text-foreground font-medium h-7 px-2"
>
<span className="text-muted-foreground text-xs truncate max-w-[120px]">
{selectedOrg?.name ?? orgSlug ?? 'Org'}
</span>
<span className="text-foreground truncate max-w-[140px]">
/ {project?.name ?? projectRef ?? 'Project'}
</span>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground shrink-0" />
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-[280px] p-0" align="start">
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search org or project..." />
<CommandList_Shadcn_>
<CommandGroup_Shadcn_ heading="Organizations">
{orgs?.map((org) => (
<CommandItem_Shadcn_
key={org.id}
value={`${org.name} ${org.slug}`}
onSelect={() => handleSelectOrg(org.slug)}
>
{org.name}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
{orgSlug && (
<CommandGroup_Shadcn_ heading="Projects">
{projects.map((proj) => (
<CommandItem_Shadcn_
key={proj.ref}
value={`${proj.name} ${proj.ref}`}
onSelect={() => handleSelectProject(proj.ref)}
>
{proj.name}
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
)}
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -0,0 +1,70 @@
'use client'
import { useUsersCountQuery } from 'data/auth/users-count-query'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useDatabaseFunctionsQuery } from 'data/database-functions/database-functions-query'
import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query'
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { usePaginatedBucketsQuery } from 'data/storage/buckets-query'
export function useV2DataCounts(projectRef: string | undefined) {
const { data: project } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: Boolean(projectRef) }
)
const conn = project?.connectionString
const { data: tables } = useTablesQuery(
{ projectRef, connectionString: conn, schema: 'public' },
{ enabled: Boolean(projectRef) }
)
const { data: usersCount } = useUsersCountQuery(
{ projectRef },
{ enabled: Boolean(projectRef) }
)
const { data: bucketsPages } = usePaginatedBucketsQuery(
{ projectRef },
{ enabled: Boolean(projectRef) }
)
const bucketsList = bucketsPages?.pages?.flatMap((p) => p) ?? []
const { data: edgeFunctions } = useEdgeFunctionsQuery(
{ projectRef },
{ enabled: Boolean(projectRef) }
)
const { data: extensions } = useDatabaseExtensionsQuery(
{ projectRef, connectionString: conn },
{ enabled: Boolean(projectRef) }
)
const { data: roles } = useDatabaseRolesQuery(
{ projectRef, connectionString: conn },
{ enabled: Boolean(projectRef) }
)
const { data: publications } = useDatabasePublicationsQuery(
{ projectRef, connectionString: conn },
{ enabled: Boolean(projectRef) }
)
const { data: types } = useEnumeratedTypesQuery(
{ projectRef, connectionString: conn },
{ enabled: Boolean(projectRef) }
)
const { data: dbFunctions } = useDatabaseFunctionsQuery(
{ projectRef, connectionString: conn },
{ enabled: Boolean(projectRef) }
)
return {
tables: Array.isArray(tables) ? tables.length : 0,
users: usersCount?.count ?? 0,
buckets: bucketsList.length,
edgeFunctions: edgeFunctions?.length ?? 0,
extensions: extensions?.length ?? 0,
roles: roles?.length ?? 0,
publications: publications?.length ?? 0,
types: types?.length ?? 0,
functions: dbFunctions?.length ?? 0,
}
}
@@ -0,0 +1,10 @@
'use client'
export function V2PlaceholderView({ title }: { title: string }) {
return (
<div className="p-6 text-muted-foreground text-sm">
<h2 className="text-foreground font-medium mb-2">{title}</h2>
<p>This view will show real data from the API. Placeholder for the v2 prototype.</p>
</div>
)
}
@@ -0,0 +1,48 @@
'use client'
import { useParams } from 'next/navigation'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
export function V2TableDetailView({ subTab }: { subTab: string }) {
const params = useParams()
const { projectRef } = useV2Params()
const tableId = params?.tableId as string
const id = tableId ? Number(tableId) : undefined
const { data: project } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: Boolean(projectRef) }
)
const { data: table, isPending, isError } = useTableEditorQuery(
{
projectRef,
connectionString: project?.connectionString,
id,
},
{ enabled: Boolean(projectRef) && typeof id === 'number' && !Number.isNaN(id) }
)
if (isError) {
return (
<div className="p-4 text-destructive text-sm">Failed to load table.</div>
)
}
if (isPending || !table) {
return <ShimmeringLoader className="m-4 h-8 rounded" />
}
return (
<div className="p-4">
<h2 className="text-sm font-medium text-foreground mb-2">
{table.schema}.{table.name} — {subTab}
</h2>
<p className="text-sm text-muted-foreground">
Content for {subTab} will use existing components (e.g. data grid, schema editor, policy editor).
</p>
</div>
)
}
@@ -0,0 +1,125 @@
'use client'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import {
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
Input,
} from 'ui'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { useV2DashboardStore } from '@/stores/v2-dashboard'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useTablesQuery } from 'data/tables/tables-query'
export function V2TablesList() {
const { projectRef } = useV2Params()
const addDetailTab = useV2DashboardStore((s) => s.addDetailTab)
const [schema, setSchema] = useState('public')
const [search, setSearch] = useState('')
const { data: project } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: Boolean(projectRef) }
)
const { data: schemas } = useSchemasQuery(
{ projectRef, connectionString: project?.connectionString },
{ enabled: Boolean(projectRef) }
)
const { data: tables, isPending, isError } = useTablesQuery(
{
projectRef,
connectionString: project?.connectionString,
schema,
includeColumns: false,
},
{ enabled: Boolean(projectRef) }
)
const filtered = useMemo(() => {
if (!Array.isArray(tables)) return []
if (!search.trim()) return tables
const q = search.toLowerCase()
return tables.filter((t) => t.name.toLowerCase().includes(q))
}, [tables, search])
const base = projectRef ? `/dashboard/v2/project/${projectRef}` : ''
const handleRowClick = (tableId: string, tableName: string) => {
const path = `${base}/data/tables/${tableId}`
addDetailTab({ id: tableId, label: tableName, path })
}
if (isError) {
return (
<div className="p-4 text-destructive text-sm">Failed to load tables.</div>
)
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-2 px-4 py-2 border-b border-border shrink-0">
<Select_Shadcn_ value={schema} onValueChange={setSchema}>
<SelectTrigger_Shadcn_ className="w-[140px] h-8 text-xs">
<SelectValue_Shadcn_ />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
{schemas?.map((s) => (
<SelectItem_Shadcn_ key={s.name} value={s.name}>
{s.name}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
<Input
placeholder="Search tables..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-[200px] h-8 text-xs"
/>
<span className="text-xs text-muted-foreground ml-auto">
{Array.isArray(tables) ? tables.length : 0} tables
</span>
</div>
<div className="flex-1 overflow-auto p-4">
{isPending ? (
<ShimmeringLoader className="h-8 w-full rounded" />
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-muted-foreground">
<th className="pb-2 pr-4 font-medium">Name</th>
<th className="pb-2 font-medium">Schema</th>
</tr>
</thead>
<tbody>
{filtered.map((table) => (
<tr
key={table.id}
className="border-b border-border/50 hover:bg-sidebar-accent/50"
>
<td className="py-2 pr-4">
<Link
href={`${base}/data/tables/${table.id}`}
onClick={() => handleRowClick(String(table.id), table.name)}
className="text-foreground hover:underline"
>
{table.name}
</Link>
</td>
<td className="py-2 text-muted-foreground">{table.schema ?? schema}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
@@ -4,7 +4,6 @@ import { sendTelemetryEvent } from 'common'
import { TelemetryEvent } from 'common/telemetry-constants'
import { handleError } from 'data/fetchers'
import { API_URL } from 'lib/constants'
import { useRouter } from 'next/router'
import type { ResponseError, UseCustomMutationOptions } from 'types'
interface SendEventVariables {
@@ -30,11 +29,10 @@ export const useSendEventMutation = ({
UseCustomMutationOptions<SendEventData, ResponseError, TelemetryEvent>,
'mutationFn'
> = {}) => {
const router = useRouter()
return useMutation<SendEventData, ResponseError, TelemetryEvent>({
mutationFn: (event) => {
return sendEvent({ event, pathname: router.pathname })
const pathname = typeof window !== 'undefined' ? window.location.pathname : undefined
return sendEvent({ event, pathname })
},
async onSuccess(data, variables, context) {
await onSuccess?.(data, variables, context)
+4 -4
View File
@@ -3,7 +3,6 @@ import { TelemetryEvent, TelemetryGroups } from 'common/telemetry-constants'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { API_URL } from 'lib/constants'
import { useRouter } from 'next/router'
import { useCallback } from 'react'
type EventMap = {
@@ -29,7 +28,8 @@ type HasProperties<A extends keyof EventMap> = EventMap[A] extends { properties:
export const useTrack = () => {
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const router = useRouter()
// Avoid Pages Router dependency in App Router.
const pathname = typeof window !== 'undefined' ? window.location.pathname : undefined
const track = useCallback(
<A extends keyof EventMap>(
@@ -52,9 +52,9 @@ export const useTrack = () => {
...(groups && { groups }),
} as EventMap[A]
sendTelemetryEvent(API_URL, event, router.pathname)
sendTelemetryEvent(API_URL, event, pathname)
},
[project?.ref, org?.slug, router.pathname]
[project?.ref, org?.slug, pathname]
)
return track
+128
View File
@@ -0,0 +1,128 @@
'use client'
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react'
export type RightPanelType = 'chat' | 'sql' | 'adv' | null
export interface DetailTab {
id: string
label: string
path: string
}
const defaultExpandedGroups: Record<string, boolean> = {
'data-database': true,
'data-auth': true,
'data-storage': true,
'data-edge-functions': true,
'data-realtime': true,
'obs-logs': true,
'obs-metrics': true,
'obs-alerts': true,
'settings-project': true,
'settings-branches': true,
'settings-modules': true,
'settings-org': true,
}
interface V2DashboardState {
detailTabs: DetailTab[]
expandedGroups: Record<string, boolean>
rightPanel: RightPanelType
addDetailTab: (tab: DetailTab) => void
removeDetailTab: (id: string) => void
setDetailTabs: (tabs: DetailTab[]) => void
toggleGroup: (groupId: string) => void
setExpandedGroup: (groupId: string, expanded: boolean) => void
toggleRightPanel: (panel: 'chat' | 'sql' | 'adv') => void
closeRightPanel: () => void
}
const V2DashboardContext = createContext<V2DashboardState | null>(null)
export function V2DashboardProvider({ children }: { children: ReactNode }) {
const [detailTabs, setDetailTabsState] = useState<DetailTab[]>([])
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>(defaultExpandedGroups)
const [rightPanel, setRightPanel] = useState<RightPanelType>(null)
const addDetailTab = useCallback((tab: DetailTab) => {
setDetailTabsState((state) =>
state.some((t) => t.id === tab.id) ? state : [...state, tab]
)
}, [])
const removeDetailTab = useCallback((id: string) => {
setDetailTabsState((state) => state.filter((t) => t.id !== id))
}, [])
const setDetailTabs = useCallback((tabs: DetailTab[]) => {
setDetailTabsState(tabs)
}, [])
const toggleGroup = useCallback((groupId: string) => {
setExpandedGroups((state) => ({
...state,
[groupId]: !state[groupId],
}))
}, [])
const setExpandedGroup = useCallback((groupId: string, expanded: boolean) => {
setExpandedGroups((state) => ({ ...state, [groupId]: expanded }))
}, [])
const toggleRightPanel = useCallback((panel: 'chat' | 'sql' | 'adv') => {
setRightPanel((current) => (current === panel ? null : panel))
}, [])
const closeRightPanel = useCallback(() => setRightPanel(null), [])
const value = useMemo<V2DashboardState>(
() => ({
detailTabs,
expandedGroups,
rightPanel,
addDetailTab,
removeDetailTab,
setDetailTabs,
toggleGroup,
setExpandedGroup,
toggleRightPanel,
closeRightPanel,
}),
[
detailTabs,
expandedGroups,
rightPanel,
addDetailTab,
removeDetailTab,
setDetailTabs,
toggleGroup,
setExpandedGroup,
toggleRightPanel,
closeRightPanel,
]
)
return (
<V2DashboardContext.Provider value={value}>{children}</V2DashboardContext.Provider>
)
}
export function useV2DashboardStore(): V2DashboardState
export function useV2DashboardStore<T>(selector: (s: V2DashboardState) => T): T
export function useV2DashboardStore<T>(selector?: (s: V2DashboardState) => T) {
const ctx = useContext(V2DashboardContext)
if (!ctx) {
throw new Error('useV2DashboardStore must be used within V2DashboardProvider')
}
if (selector) return selector(ctx)
return ctx
}
@@ -1,5 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { GripVertical } from 'lucide-react'
import * as ResizablePrimitive from 'react-resizable-panels'
@@ -22,7 +23,8 @@ const serverCompatibleLocalStorage = {
},
}
const ResizablePanelGroupWithPersistence = ({
/** Only mounts after hydration so `useDefaultLayout` never reads localStorage during SSR / first paint. */
const ResizablePanelGroupPersisted = ({
className,
autoSaveId,
defaultLayout: defaultLayoutProp,
@@ -45,6 +47,42 @@ const ResizablePanelGroupWithPersistence = ({
)
}
const ResizablePanelGroupWithPersistence = ({
className,
autoSaveId,
defaultLayout: defaultLayoutProp,
onLayoutChanged: onLayoutChangedProp,
...props
}: ResizablePrimitive.GroupProps & { autoSaveId: string }) => {
const [hasMounted, setHasMounted] = useState(false)
useEffect(() => {
setHasMounted(true)
}, [])
if (!hasMounted) {
return (
<ResizablePrimitive.Group
defaultLayout={defaultLayoutProp}
onLayoutChanged={onLayoutChangedProp}
className={cn('flex h-full w-full aria-[orientation=vertical]:flex-col', className)}
data-slot="resizable-panel-group"
{...props}
/>
)
}
return (
<ResizablePanelGroupPersisted
autoSaveId={autoSaveId}
className={className}
defaultLayout={defaultLayoutProp}
onLayoutChanged={onLayoutChangedProp}
{...props}
/>
)
}
const ResizablePanelGroup = ({
className,
autoSaveId,