mirror of
https://github.com/supabase/supabase.git
synced 2026-10-10 20:05:06 +03:00
tabs system
This commit is contained in:
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" />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user