tabs system

This commit is contained in:
Francesco Sansalvadore committed 2026-03-19 23:50:09 +01:00
1 parent 2a1e8c097b
commit b0e7bc52d3
23 files changed
+659 -152

No files matched your search

@@ -1,5 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataBucketsPage() {
return <V2PlaceholderView title="Storage buckets" />
return <V2PlaceholderView title="Storage buckets" tabCategory="buckets" />
}
@@ -1,5 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataChannelsPage() {
return <V2PlaceholderView title="Realtime channels" />
return <V2PlaceholderView title="Realtime channels" tabCategory="channels" />
}
@@ -1,5 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataEdgeFunctionsPage() {
return <V2PlaceholderView title="Edge functions" />
return <V2PlaceholderView title="Edge functions" tabCategory="edge-functions" />
}
@@ -1,5 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataExtensionsPage() {
return <V2PlaceholderView title="Extensions" />
return <V2PlaceholderView title="Extensions" tabCategory="extensions" />
}
@@ -1,5 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataFunctionsPage() {
return <V2PlaceholderView title="Database functions" />
return <V2PlaceholderView title="Database functions" tabCategory="functions" />
}
@@ -1,5 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataIndexesPage() {
return <V2PlaceholderView title="Indexes" />
return <V2PlaceholderView title="Indexes" tabCategory="indexes" />
}
@@ -1,5 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataOAuthAppsPage() {
return <V2PlaceholderView title="OAuth apps" />
return <V2PlaceholderView title="OAuth apps" tabCategory="oauth-apps" />
}
@@ -0,0 +1,5 @@
import { Chooser } from '@/components/v2/Chooser'
export default function DataChooserPage() {
return <Chooser />
}
@@ -1,5 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataProvidersPage() {
return <V2PlaceholderView title="Auth providers" />
return <V2PlaceholderView title="Auth providers" tabCategory="providers" />
}
@@ -1,5 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataPublicationsPage() {
return <V2PlaceholderView title="Publications" />
return <V2PlaceholderView title="Publications" tabCategory="publications" />
}
@@ -1,5 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataRolesPage() {
return <V2PlaceholderView title="Roles" />
return <V2PlaceholderView title="Roles" tabCategory="roles" />
}
@@ -1,5 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataTypesPage() {
return <V2PlaceholderView title="Enumerated types" />
return <V2PlaceholderView title="Enumerated types" tabCategory="types" />
}
@@ -1,5 +1,5 @@
import { V2PlaceholderView } from '@/components/v2/views/V2PlaceholderView'
export default function DataUsersPage() {
return <V2PlaceholderView title="Auth users" />
return <V2PlaceholderView title="Auth users" tabCategory="users" />
}
+233
View File
@@ -0,0 +1,233 @@
'use client'
import {
Box,
Database,
File,
Fingerprint,
FunctionSquare,
Globe,
HardDrive,
KeyRound,
Layers,
ShieldCheck,
Table2,
Users,
Zap,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { cn } from 'ui'
import { Input } from 'ui/src/components/shadcn/ui/input'
import { TypeBadge } from './TypeBadge'
import { useV2DataCounts } from './useV2DataCounts'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import {
CATEGORY_DOMAIN,
CATEGORY_LABELS,
useV2DashboardStore,
type DataTabDomain,
type RecentItem,
} from '@/stores/v2-dashboard'
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
tables: <Table2 className="h-5 w-5" strokeWidth={1.5} />,
functions: <FunctionSquare className="h-5 w-5" strokeWidth={1.5} />,
types: <Layers className="h-5 w-5" strokeWidth={1.5} />,
roles: <ShieldCheck className="h-5 w-5" strokeWidth={1.5} />,
extensions: <Box className="h-5 w-5" strokeWidth={1.5} />,
indexes: <Database className="h-5 w-5" strokeWidth={1.5} />,
publications: <Globe className="h-5 w-5" strokeWidth={1.5} />,
users: <Users className="h-5 w-5" strokeWidth={1.5} />,
providers: <Fingerprint className="h-5 w-5" strokeWidth={1.5} />,
'oauth-apps': <KeyRound className="h-5 w-5" strokeWidth={1.5} />,
buckets: <HardDrive className="h-5 w-5" strokeWidth={1.5} />,
'edge-functions': <Zap className="h-5 w-5" strokeWidth={1.5} />,
channels: <File className="h-5 w-5" strokeWidth={1.5} />,
}
const CATEGORY_GROUPS: Array<{ label: string; categories: string[] }> = [
{
label: 'Database',
categories: ['tables', 'functions', 'types', 'roles', 'extensions', 'indexes', 'publications'],
},
{
label: 'Auth',
categories: ['users', 'providers', 'oauth-apps'],
},
{
label: 'Storage',
categories: ['buckets'],
},
{
label: 'Compute',
categories: ['edge-functions', 'channels'],
},
]
const COUNT_KEY_MAP: Record<string, keyof ReturnType<typeof useV2DataCounts>> = {
tables: 'tables',
functions: 'functions',
types: 'types',
roles: 'roles',
extensions: 'extensions',
indexes: 'indexes',
publications: 'publications',
users: 'users',
buckets: 'buckets',
'edge-functions': 'edgeFunctions',
}
function formatCount(n: number, label: string) {
return `${n} ${label.toLowerCase()}`
}
function timeAgo(timestamp: number): string {
const diff = Date.now() - timestamp
const minutes = Math.floor(diff / 60_000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export function Chooser() {
const router = useRouter()
const { projectRef } = useV2Params()
const { openDataTab, recentItems } = useV2DashboardStore()
const counts = useV2DataCounts(projectRef)
const [search, setSearch] = useState('')
const lowerSearch = search.toLowerCase()
const base = projectRef ? `/dashboard/v2/project/${projectRef}` : ''
const openCategory = (category: string) => {
const domain = CATEGORY_DOMAIN[category] ?? 'db'
const path = `${base}/data/${category}`
openDataTab({
id: category,
label: CATEGORY_LABELS[category] ?? category,
type: 'list',
category,
domain,
path,
})
router.push(path)
}
const openRecent = (item: RecentItem) => {
openDataTab({
id: item.id,
label: item.label,
type: 'detail',
category: item.category,
domain: item.domain,
path: item.path,
})
router.push(item.path)
}
const filteredGroups = CATEGORY_GROUPS.map((group) => ({
...group,
categories: group.categories.filter((c) => {
if (!lowerSearch) return true
return (
c.toLowerCase().includes(lowerSearch) ||
(CATEGORY_LABELS[c] ?? c).toLowerCase().includes(lowerSearch)
)
}),
})).filter((g) => g.categories.length > 0)
const filteredRecents = recentItems.filter(
(r) =>
!lowerSearch ||
r.label.toLowerCase().includes(lowerSearch) ||
r.category.toLowerCase().includes(lowerSearch)
)
return (
<div className="flex flex-col gap-8 p-6 max-w-4xl mx-auto w-full">
{/* Search */}
<div>
<Input
autoFocus
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tables, users, buckets, functions…"
className="w-full"
/>
</div>
{/* Category groups */}
{filteredGroups.map((group) => (
<div key={group.label}>
<h3 className="text-xs text-foreground-lighter uppercase tracking-wider mb-3">
{group.label}
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{group.categories.map((cat) => {
const domain = CATEGORY_DOMAIN[cat] ?? 'db'
const label = CATEGORY_LABELS[cat] ?? cat
const countKey = COUNT_KEY_MAP[cat]
const count = countKey != null ? counts[countKey] : undefined
return (
<button
key={cat}
type="button"
onClick={() => openCategory(cat)}
className="flex flex-col items-start gap-2 rounded-md border border-border p-3 text-left bg-surface-100 hover:bg-sidebar-accent/50 hover:border-foreground/20 transition-colors"
>
<div className="flex items-center justify-between w-full">
<span className="text-foreground-lighter">{CATEGORY_ICONS[cat]}</span>
<TypeBadge domain={domain as DataTabDomain} type="list" />
</div>
<div>
<div className="text-sm text-foreground-light">{label}</div>
{count != null && (
<div className="text-xs text-foreground-lighter mt-0.5">
{formatCount(count, label)}
</div>
)}
</div>
</button>
)
})}
</div>
</div>
))}
{/* Recents */}
{filteredRecents.length > 0 && (
<div>
<h3 className="text-xs text-foreground-lighter uppercase tracking-wider mb-3">Recent</h3>
<div className="flex flex-col divide-y divide-border border border-border rounded-md overflow-hidden">
{filteredRecents.slice(0, 10).map((item) => (
<button
key={item.id}
type="button"
onClick={() => openRecent(item)}
className="flex items-center gap-3 px-3 py-2.5 text-left hover:bg-sidebar-accent/50 transition-colors"
>
<TypeBadge domain={item.domain} type="detail" />
<span className="flex-1 min-w-0 truncate text-sm text-foreground-light">
{item.label}
</span>
<span className="text-xs text-foreground-lighter shrink-0">
{timeAgo(item.timestamp)}
</span>
</button>
))}
</div>
</div>
)}
{filteredGroups.length === 0 && filteredRecents.length === 0 && (
<p className="text-sm text-foreground-lighter">No results for "{search}"</p>
)}
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
'use client'
import { Plus, X } from 'lucide-react'
import { useRouter, usePathname } from 'next/navigation'
import { cn } from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { useV2DashboardStore, type DataTab } from '@/stores/v2-dashboard'
import { TypeBadge } from './TypeBadge'
/** Returns the most specific (longest-path) tab that the current pathname is under. */
function useActiveTab(dataTabs: DataTab[], pathname: string | null): DataTab | null {
if (!pathname) return null
const matches = dataTabs.filter(
(t) => pathname === t.path || pathname.startsWith(t.path + '/')
)
if (matches.length === 0) return null
return matches.reduce((best, t) => (t.path.length > best.path.length ? t : best))
}
export function DataTabBar() {
const router = useRouter()
const pathname = usePathname()
const { projectRef } = useV2Params()
const { dataTabs, closeDataTab } = useV2DashboardStore()
const activeTab = useActiveTab(dataTabs, pathname)
const chooserPath = projectRef ? `/dashboard/v2/project/${projectRef}/data` : '#'
const handleClose = (tab: DataTab, e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
closeDataTab(tab.id)
if (activeTab?.id === tab.id) {
const remaining = dataTabs.filter((t) => t.id !== tab.id)
if (remaining.length > 0) {
const idx = dataTabs.indexOf(tab)
const next = remaining[Math.min(idx, remaining.length - 1)]
router.push(next.path)
} else {
router.push(chooserPath)
}
}
}
const isChooser = !activeTab && pathname !== null
return (
<div className="flex items-center border-b border-border bg-background shrink-0 min-h-[36px] overflow-x-auto">
{dataTabs.map((tab) => {
const isActive = activeTab?.id === tab.id
return (
<div
key={tab.id}
className={cn(
'group relative flex items-center gap-1.5 pl-2.5 pr-1 py-1.5 border-r border-border shrink-0 max-w-[200px] cursor-pointer select-none',
isActive
? 'bg-background text-foreground after:absolute after:bottom-0 after:left-0 after:right-0 after:h-[2px] after:bg-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50'
)}
onClick={() => router.push(tab.path)}
>
<TypeBadge domain={tab.domain} type={tab.type} />
<span className="flex-1 min-w-0 truncate text-xs">{tab.label}</span>
<button
type="button"
onClick={(e) => handleClose(tab, e)}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-sidebar-accent text-muted-foreground hover:text-foreground shrink-0"
aria-label={`Close ${tab.label}`}
>
<X className="h-3 w-3" />
</button>
</div>
)
})}
{/* "+" button — opens chooser */}
<button
type="button"
onClick={() => router.push(chooserPath)}
className={cn(
'flex items-center justify-center w-8 h-[36px] shrink-0 text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/50',
isChooser && 'text-foreground bg-sidebar-accent/50'
)}
title="Open data chooser"
aria-label="Open data chooser"
>
<Plus className="h-4 w-4" />
</button>
</div>
)
}
+28 -19
View File
@@ -5,7 +5,7 @@ import { cn } from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { TabBar } from './TabBar'
import { DataTabBar } from './DataTabBar'
import { StaticTitle } from './StaticTitle'
import { V2EditorFooter } from './V2EditorFooter'
@@ -13,36 +13,45 @@ 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')
// Any /data route (including the chooser at /data with no further segment)
const isDataActivity =
Boolean(projectRef) &&
Boolean(pathname?.includes('/data/') || pathname?.endsWith('/data'))
// Sub-routes deeper than /data (list + detail views) need overflow:hidden for grids
const isDataSubRoute =
Boolean(projectRef) &&
Boolean(
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')
)
const isHome =
Boolean(projectRef) &&
pathname?.endsWith(`/${projectRef}`) &&
!pathname?.includes('/data/') &&
!pathname?.includes('/data') &&
!pathname?.includes('/obs/') &&
!pathname?.includes('/settings/')
return (
<div className="flex flex-col flex-1 min-w-0 overflow-hidden bg-background">
{isDataCategory ? null : isHome ? null : <StaticTitle />}
{isDataCategory && <TabBar />}
{!isDataActivity && !isHome && <StaticTitle />}
{isDataActivity && <DataTabBar />}
<div
className={cn(
'flex min-h-0 flex-1 flex-col',
isDataCategory ? 'overflow-hidden' : 'overflow-auto'
isDataSubRoute ? 'overflow-hidden' : 'overflow-auto'
)}
>
{children}
+12 -5
View File
@@ -16,15 +16,22 @@ import { V2DashboardProvider } from '@/stores/v2-dashboard'
export function Shell({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const { projectRef, orgSlug } = useV2Params()
const { projectRef } = useV2Params()
const isHomeActive =
Boolean(projectRef && orgSlug) &&
Boolean(projectRef) &&
pathname?.endsWith(`/${projectRef}`) &&
!pathname?.includes('/data/') &&
!pathname?.includes('/data') &&
!pathname?.includes('/obs/') &&
!pathname?.includes('/settings/')
// Data activity also hides the browser panel (Figma tab model)
const isDataActivity =
Boolean(projectRef) &&
Boolean(pathname?.includes('/data/') || pathname?.endsWith('/data'))
const hideBrowser = isHomeActive || isDataActivity
return (
<V2DashboardProvider>
<SidebarProvider defaultOpen={false}>
@@ -37,9 +44,9 @@ export function Shell({ children }: { children: React.ReactNode }) {
<ResizablePanelGroup
orientation="horizontal"
className="h-full w-full overflow-x-hidden flex-1 flex flex-row gap-0"
autoSaveId={isHomeActive ? 'v2-shell-content-home' : 'v2-shell-content'}
autoSaveId={hideBrowser ? 'v2-shell-content-wide' : 'v2-shell-content'}
>
{!isHomeActive && (
{!hideBrowser && (
<>
<ResizablePanel
id="panel-browser"
+57
View File
@@ -0,0 +1,57 @@
'use client'
import { cn } from 'ui'
import type { DataTabDomain, DataTabType } from '@/stores/v2-dashboard'
type BadgeVariant = DataTabDomain | 'list'
const BADGE_CONFIG: Record<BadgeVariant, { label: string; className: string }> = {
list: {
label: 'LIST',
className: 'bg-muted text-foreground-lighter',
},
db: {
label: 'DB',
className: 'bg-blue-500/15 text-blue-600 dark:text-blue-400',
},
auth: {
label: 'AUTH',
className: 'bg-purple-500/15 text-purple-600 dark:text-purple-400',
},
st: {
label: 'STOR',
className: 'bg-green-500/15 text-green-600 dark:text-green-400',
},
fn: {
label: 'FN',
className: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
},
rt: {
label: 'RT',
className: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
},
}
interface TypeBadgeProps {
domain: DataTabDomain
type: DataTabType
className?: string
}
export function TypeBadge({ domain, type, className }: TypeBadgeProps) {
const variant: BadgeVariant = type === 'list' ? 'list' : domain
const { label, className: badgeClass } = BADGE_CONFIG[variant]
return (
<span
className={cn(
'inline-flex items-center rounded px-1 text-[9px] font-semibold leading-4 tracking-wide shrink-0',
badgeClass,
className
)}
>
{label}
</span>
)
}
@@ -1,8 +1,42 @@
'use client'
export function V2PlaceholderView({ title }: { title: string }) {
import { useEffect } from 'react'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { CATEGORY_DOMAIN, useV2DashboardStore, type DataTabDomain } from '@/stores/v2-dashboard'
interface V2PlaceholderViewProps {
title: string
/** If provided, registers this view as a list tab on mount */
tabCategory?: string
tabLabel?: string
tabDomain?: DataTabDomain
}
export function V2PlaceholderView({
title,
tabCategory,
tabLabel,
tabDomain,
}: V2PlaceholderViewProps) {
const { projectRef } = useV2Params()
const openDataTab = useV2DashboardStore((s) => s.openDataTab)
useEffect(() => {
if (!tabCategory || !projectRef) return
const domain = tabDomain ?? CATEGORY_DOMAIN[tabCategory] ?? 'db'
openDataTab({
id: tabCategory,
label: tabLabel ?? title,
type: 'list',
category: tabCategory,
domain,
path: `/dashboard/v2/project/${projectRef}/data/${tabCategory}`,
})
}, [projectRef, tabCategory, tabLabel, tabDomain, title, openDataTab])
return (
<div className="p-6 text-muted-foreground text-sm">
<div className="p-6 text-foreground-lighter 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>
@@ -1,17 +1,20 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { TableGridEditor } from 'components/interfaces/TableGridEditor/TableGridEditor'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { useV2DashboardStore } from '@/stores/v2-dashboard'
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 openDataTab = useV2DashboardStore((s) => s.openDataTab)
const tableId = params?.tableId as string
const id = tableId ? Number(tableId) : undefined
@@ -28,6 +31,19 @@ export function V2TableDetailView({ subTab }: { subTab: string }) {
{ enabled: Boolean(projectRef) && typeof id === 'number' && !Number.isNaN(id) }
)
// Register this detail tab once we know the table name
useEffect(() => {
if (!table || !projectRef || !tableId) return
openDataTab({
id: `tables-${tableId}`,
label: table.name,
type: 'detail',
category: 'tables',
domain: 'db',
path: `/dashboard/v2/project/${projectRef}/data/tables/${tableId}/data`,
})
}, [table, projectRef, tableId, openDataTab])
if (isError) {
return (
<div className="p-4 text-destructive text-sm">Failed to load table.</div>
@@ -2,7 +2,7 @@
import type { PostgresTable } from '@supabase/postgres-meta'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Select_Shadcn_,
SelectContent_Shadcn_,
@@ -23,8 +23,21 @@ import { useTablesQuery } from 'data/tables/tables-query'
export function V2TablesList() {
const { projectRef } = useV2Params()
const addDetailTab = useV2DashboardStore((s) => s.addDetailTab)
const openDataTab = useV2DashboardStore((s) => s.openDataTab)
const [schema, setSchema] = useState('public')
// Register this list as an open tab
useEffect(() => {
if (!projectRef) return
openDataTab({
id: 'tables',
label: 'Tables',
type: 'list',
category: 'tables',
domain: 'db',
path: `/dashboard/v2/project/${projectRef}/data/tables`,
})
}, [projectRef, openDataTab])
const [search, setSearch] = useState('')
const { data: project, isPending: isProjectPending } = useProjectDetailQuery(
@@ -57,10 +70,20 @@ export function V2TablesList() {
const base = projectRef ? `/dashboard/v2/project/${projectRef}` : ''
const openTable = (table: PostgresTable) => {
const path = `${base}/data/tables/${table.id}`
addDetailTab({ id: String(table.id), label: table.name, path })
}
const openTable = useCallback(
(table: PostgresTable) => {
const path = `${base}/data/tables/${table.id}/data`
openDataTab({
id: `tables-${table.id}`,
label: table.name,
type: 'detail',
category: 'tables',
domain: 'db',
path,
})
},
[base, openDataTab]
)
const columns = useMemo<DataListColumnDef<PostgresTable>[]>(
() => [
@@ -71,7 +94,7 @@ export function V2TablesList() {
sortValue: (row) => row.name,
renderCell: (row) => (
<Link
href={`${base}/data/tables/${row.id}`}
href={`${base}/data/tables/${row.id}/data`}
onClick={() => openTable(row)}
className="truncate font-mono text-foreground hover:underline"
>
@@ -101,7 +124,7 @@ export function V2TablesList() {
),
},
],
[base, schema]
[base, schema, openTable]
)
if (isTablesError) {
@@ -147,7 +170,7 @@ export function V2TablesList() {
emptyMessage={
search.trim() ? 'No tables match your filter.' : 'No tables in this schema.'
}
onRowDoubleClick={(row) => openTable(row)}
onRowDoubleClick={openTable}
/>
</div>
)
@@ -1,20 +1,26 @@
import { useRouter } from 'next/router'
import { useRouter as useCompatRouter } from 'next/compat/router'
import { usePathname, useRouter as useAppRouter, useSearchParams } from 'next/navigation'
import { useCallback, useMemo } from 'react'
export const useTableEditorFiltersSort = () => {
const router = useRouter()
// Returns the pages-router instance, or null when running in the app router.
const pagesRouter = useCompatRouter()
// Always call navigation hooks unconditionally (Rules of Hooks).
const appRouter = useAppRouter()
const appSearchParams = useSearchParams()
const pathname = usePathname()
const isAppRouter = pagesRouter === null
const urlParams = useMemo(() => {
return new URLSearchParams(router.asPath.split('?')[1])
}, [router.asPath])
if (isAppRouter) {
return appSearchParams ?? new URLSearchParams()
}
return new URLSearchParams(pagesRouter?.asPath.split('?')[1])
}, [isAppRouter, pagesRouter, appSearchParams])
const filters = useMemo(() => {
return urlParams.getAll('filter')
}, [urlParams])
const sorts = useMemo(() => {
return urlParams.getAll('sort')
}, [urlParams])
const filters = useMemo(() => urlParams.getAll('filter'), [urlParams])
const sorts = useMemo(() => urlParams.getAll('sort'), [urlParams])
type SetParamsArgs = {
filter?: string[]
@@ -26,28 +32,37 @@ export const useTableEditorFiltersSort = () => {
const prevParams = { filter: filters, sort: sorts }
const newParams = fn(prevParams)
const hasFilter = newParams.filter !== undefined
const hasSort = newParams.sort !== undefined
router.push(
{
query: {
...router.query,
...(hasFilter ? { filter: newParams.filter } : {}),
...(hasSort ? { sort: newParams.sort } : {}),
if (isAppRouter) {
// Build a new search-params string and replace the URL shallowly.
const next = new URLSearchParams(appSearchParams?.toString() ?? '')
if (newParams.filter !== undefined) {
next.delete('filter')
for (const f of newParams.filter) next.append('filter', f)
}
if (newParams.sort !== undefined) {
next.delete('sort')
for (const s of newParams.sort) next.append('sort', s)
}
const qs = next.toString()
appRouter.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false })
} else {
const hasFilter = newParams.filter !== undefined
const hasSort = newParams.sort !== undefined
pagesRouter?.push(
{
query: {
...pagesRouter.query,
...(hasFilter ? { filter: newParams.filter } : {}),
...(hasSort ? { sort: newParams.sort } : {}),
},
},
},
undefined,
{ shallow: true }
)
undefined,
{ shallow: true }
)
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[filters, sorts]
[isAppRouter, pagesRouter, appRouter, appSearchParams, pathname, filters, sorts]
)
return {
filters,
sorts,
setParams,
}
return { filters, sorts, setParams }
}
+90 -74
View File
@@ -11,10 +11,15 @@ import {
} from 'react'
export type RightPanelType = 'chat' | 'sql' | 'adv' | null
export type DataTabDomain = 'db' | 'auth' | 'st' | 'fn' | 'rt'
export type DataTabType = 'list' | 'detail'
export interface DetailTab {
export interface DataTab {
id: string
label: string
type: DataTabType
category: string
domain: DataTabDomain
path: string
}
@@ -22,18 +27,47 @@ export interface RecentItem {
id: string
label: string
path: string
type: string
category: string
domain: DataTabDomain
timestamp: number
}
// Exported so components can reference canonical mappings
export const CATEGORY_DOMAIN: Record<string, DataTabDomain> = {
tables: 'db',
functions: 'db',
types: 'db',
roles: 'db',
extensions: 'db',
indexes: 'db',
publications: 'db',
users: 'auth',
providers: 'auth',
'oauth-apps': 'auth',
buckets: 'st',
'edge-functions': 'fn',
channels: 'rt',
}
export 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',
}
const RECENT_ITEMS_STORAGE_KEY = 'v2-recent-items'
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,
@@ -44,15 +78,15 @@ const defaultExpandedGroups: Record<string, boolean> = {
}
interface V2DashboardState {
detailTabs: DetailTab[]
// Flat data tabs (Figma model)
dataTabs: DataTab[]
expandedGroups: Record<string, boolean>
rightPanel: RightPanelType
recentItems: RecentItem[]
addDetailTab: (tab: DetailTab) => void
openDataTab: (tab: DataTab) => void
closeDataTab: (id: string) => void
addRecentItem: (item: Omit<RecentItem, 'timestamp'>) => 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
@@ -61,29 +95,13 @@ interface V2DashboardState {
const V2DashboardContext = createContext<V2DashboardState | null>(null)
const inferRecentTypeFromPath = (path: string): string => {
if (path.includes('/data/tables/')) return 'Table'
if (path.includes('/data/functions/')) return 'Function'
if (path.includes('/data/types/')) return 'Type'
if (path.includes('/data/roles/')) return 'Role'
if (path.includes('/data/users/')) return 'User'
if (path.includes('/data/buckets/')) return 'Bucket'
if (path.includes('/data/extensions/')) return 'Extension'
if (path.includes('/data/indexes/')) return 'Index'
if (path.includes('/data/providers/')) return 'Provider'
if (path.includes('/data/oauth-apps/')) return 'OAuth App'
if (path.includes('/data/publications/')) return 'Publication'
if (path.includes('/data/edge-functions/')) return 'Edge Function'
if (path.includes('/data/channels/')) return 'Channel'
return 'Item'
}
export function V2DashboardProvider({ children }: { children: ReactNode }) {
const [detailTabs, setDetailTabsState] = useState<DetailTab[]>([])
const [dataTabs, setDataTabs] = useState<DataTab[]>([])
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>(defaultExpandedGroups)
const [rightPanel, setRightPanel] = useState<RightPanelType>(null)
const [recentItems, setRecentItems] = useState<RecentItem[]>([])
// Load persisted recents from localStorage once
useEffect(() => {
if (typeof window === 'undefined') return
try {
@@ -94,65 +112,69 @@ export function V2DashboardProvider({ children }: { children: ReactNode }) {
const normalized = parsed
.filter((x) => x && typeof x === 'object')
.map((x) => x as Partial<RecentItem>)
.filter((x) => typeof x.id === 'string' && typeof x.label === 'string' && typeof x.path === 'string')
.filter(
(x) =>
typeof x.id === 'string' &&
typeof x.label === 'string' &&
typeof x.path === 'string'
)
.map((x) => ({
id: x.id as string,
label: x.label as string,
path: x.path as string,
type: typeof x.type === 'string' ? x.type : 'Item',
category: typeof x.category === 'string' ? x.category : 'tables',
domain: (typeof x.domain === 'string' ? x.domain : 'db') as DataTabDomain,
timestamp: typeof x.timestamp === 'number' ? x.timestamp : 0,
}))
setRecentItems(normalized.sort((a, b) => b.timestamp - a.timestamp).slice(0, 20))
} catch {
// Ignore corrupted localStorage.
// Ignore corrupted localStorage
}
}, [])
// Persist recents to localStorage on change
useEffect(() => {
if (typeof window === 'undefined') return
try {
localStorage.setItem(RECENT_ITEMS_STORAGE_KEY, JSON.stringify(recentItems))
} catch {
// Ignore write failures (e.g. quota).
// Ignore write failures
}
}, [recentItems])
const addRecentItem = useCallback((item: Omit<RecentItem, 'timestamp'>) => {
const now = Date.now()
setRecentItems((prev) => {
const withoutDup = prev.filter((x) => x.id !== item.id)
const next: RecentItem = { ...item, timestamp: now }
return [next, ...withoutDup].slice(0, 20)
const without = prev.filter((x) => x.id !== item.id)
return [{ ...item, timestamp: Date.now() }, ...without].slice(0, 20)
})
}, [])
const addDetailTab = useCallback((tab: DetailTab) => {
addRecentItem({
id: tab.id,
label: tab.label,
path: tab.path,
type: inferRecentTypeFromPath(tab.path),
})
const openDataTab = useCallback(
(tab: DataTab) => {
setDataTabs((prev) => {
if (prev.some((t) => t.id === tab.id)) return prev
return [...prev, tab]
})
// Add to recents for detail tabs only
if (tab.type === 'detail') {
addRecentItem({
id: tab.id,
label: tab.label,
path: tab.path,
category: tab.category,
domain: tab.domain,
})
}
},
[addRecentItem]
)
setDetailTabsState((state) =>
state.some((t) => t.id === tab.id) ? state : [...state, tab]
)
}, [addRecentItem])
const removeDetailTab = useCallback((id: string) => {
setDetailTabsState((state) => state.filter((t) => t.id !== id))
}, [])
const setDetailTabs = useCallback((tabs: DetailTab[]) => {
setDetailTabsState(tabs)
const closeDataTab = useCallback((id: string) => {
setDataTabs((prev) => prev.filter((t) => t.id !== id))
}, [])
const toggleGroup = useCallback((groupId: string) => {
setExpandedGroups((state) => ({
...state,
[groupId]: !state[groupId],
}))
setExpandedGroups((state) => ({ ...state, [groupId]: !state[groupId] }))
}, [])
const setExpandedGroup = useCallback((groupId: string, expanded: boolean) => {
@@ -167,28 +189,26 @@ export function V2DashboardProvider({ children }: { children: ReactNode }) {
const value = useMemo<V2DashboardState>(
() => ({
detailTabs,
dataTabs,
expandedGroups,
rightPanel,
recentItems,
addDetailTab,
openDataTab,
closeDataTab,
addRecentItem,
removeDetailTab,
setDetailTabs,
toggleGroup,
setExpandedGroup,
toggleRightPanel,
closeRightPanel,
}),
[
detailTabs,
dataTabs,
expandedGroups,
rightPanel,
recentItems,
addDetailTab,
openDataTab,
closeDataTab,
addRecentItem,
removeDetailTab,
setDetailTabs,
toggleGroup,
setExpandedGroup,
toggleRightPanel,
@@ -196,18 +216,14 @@ export function V2DashboardProvider({ children }: { children: ReactNode }) {
]
)
return (
<V2DashboardContext.Provider value={value}>{children}</V2DashboardContext.Provider>
)
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 (!ctx) throw new Error('useV2DashboardStore must be used within V2DashboardProvider')
if (selector) return selector(ctx)
return ctx
}