mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(docs): main troubleshooting page (#29120)
* feat(docs): main troubleshooting page * feat(docs): global troubleshooting page layout * feat(docs): global troubleshooting page filter logic * enhance(docs): troubleshooting page ui & filters Improve UX and accessibility of global troubleshooting search page. - Implement TroubleshootingFilterStateProvider for managing filter state - Add TroubleshootingFilterEmptyState component - Improve TroubleshootingFilter UI with applied filters display - Update TroubleshootingPreview component with better layout and accessibility - Add TroubleshootingEntryAssociatedErrors component for displaying related errors - Refactor Troubleshooting utils and schemas - Update troubleshooting content template * feat(docs): individual troubleshooting pages Add individual pages for detailed information on troubleshooting entries. Small fixes to UI for troubleshooting displays. - Wrap GlobalTroubleshootingPage with SidebarSkeleton - Add TroubleshootingErrorListDetailed component for detailed error display - Implement TroubleshootingBackLink component for navigation - Update TroubleshootingPreview to use Next.js Link and include parent page - Refactor Footer component to accept className prop - Add hideFooter option to SidebarSkeleton - Minor updates to TopNavBar and other utility functions * fix: minor styling issues * tweak(troubleshooting docs): use breadcrumbs * tweak(troubleshooting docs): use sidebar for filter * enhance(troubleshooting docs): navigation, keywords Better navigation for individual troubleshooting entries, using a version of the sidebar + keywords handling via search params for good linking behavior Details: - Add TroubleshootingSidebar to TroubleshootingPage - Implement query state management for keywords using nuqs - Improve accessibility and styling of troubleshooting components - Update empty state handling and related keywords display - Upgrade nuqs package to version 1.19.1 * fix(docs): wrap useSearchParams in Suspense * seo(docs): add canonical link to troubleshooting * enhance(docs,troubleshooting): auto-reveal errors On troubleshooting search pages, if the searched term matches a hidden error, auto-reveal it when searching. When the search term is cleared, hide programmatically-opened errors while preserving user-opened ones. * enhance(docs,troubleshooting): search bar Add search bar to individual troubleshooting pages * fix(docs): breadcrumb links Fix breadcrumb linking so it uses next/link instead of reloading entire app on navigation. * fix(docs): typo * enh(docs): new troubleshooting design * enh(docs): use new multi-select * enh(docs): troubleshooting responsive styles * Update apps/docs/content/troubleshooting/monitor-supavisor-postgres-connections.mdx
This commit is contained in:
1 parent
78e10c8989
commit
a29dc5de4b
38 files changed
+1405
-78
No files matched your search
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,37 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import TroubleshootingPage from '~/features/docs/Troubleshooting.page'
|
||||
import { getAllTroubleshootingEntries, getArticleSlug } from '~/features/docs/Troubleshooting.utils'
|
||||
import { PROD_URL } from '~/lib/constants'
|
||||
|
||||
export default async function TroubleshootingEntryPage({
|
||||
params: { slug },
|
||||
}: {
|
||||
params: { slug: string }
|
||||
}) {
|
||||
const allTroubleshootingEntries = await getAllTroubleshootingEntries()
|
||||
const entry = allTroubleshootingEntries.find((entry) => getArticleSlug(entry.data) === slug)
|
||||
|
||||
if (!entry) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <TroubleshootingPage entry={entry} />
|
||||
}
|
||||
|
||||
export const generateMetadata = async ({ params: { slug } }: { params: { slug: string } }) => {
|
||||
const allTroubleshootingEntries = await getAllTroubleshootingEntries()
|
||||
const entry = allTroubleshootingEntries.find((entry) => getArticleSlug(entry.data) === slug)
|
||||
|
||||
return {
|
||||
title: 'Supabase Docs | Troubleshooting' + (entry ? ` | ${entry.data.title}` : ''),
|
||||
alternates: {
|
||||
canonical: `${PROD_URL}/guides/troubleshooting/${slug}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const generateStaticParams = async () => {
|
||||
const allTroubleshootingEntries = await getAllTroubleshootingEntries()
|
||||
return allTroubleshootingEntries.map((entry) => ({ slug: getArticleSlug(entry.data) }))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type Metadata } from 'next'
|
||||
|
||||
import { TroubleshootingPreview } from '~/features/docs/Troubleshooting.ui'
|
||||
import {
|
||||
TroubleshootingFilter,
|
||||
TroubleshootingFilterEmptyState,
|
||||
TroubleshootingListController,
|
||||
} from '~/features/docs/Troubleshooting.ui.client'
|
||||
import {
|
||||
getAllTroubleshootingEntries,
|
||||
getAllTroubleshootingErrors,
|
||||
getAllTroubleshootingKeywords,
|
||||
getAllTroubleshootingProducts,
|
||||
} from '~/features/docs/Troubleshooting.utils'
|
||||
import { TROUBLESHOOTING_CONTAINER_ID } from '~/features/docs/Troubleshooting.utils.shared'
|
||||
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
|
||||
import { PROD_URL } from '~/lib/constants'
|
||||
|
||||
export default async function GlobalTroubleshootingPage() {
|
||||
const troubleshootingEntries = await getAllTroubleshootingEntries()
|
||||
const keywords = await getAllTroubleshootingKeywords()
|
||||
const products = await getAllTroubleshootingProducts()
|
||||
const errors = await getAllTroubleshootingErrors()
|
||||
|
||||
return (
|
||||
<SidebarSkeleton className="w-full max-w-screen-lg mx-auto">
|
||||
<div className="py-8 px-5">
|
||||
<h1 className="text-4xl tracking-tight mb-7">Troubleshooting</h1>
|
||||
<p className="text-lg text-foreground-light">
|
||||
Search or browse our troubleshooting guides for solutions to common Supabase issues.
|
||||
</p>
|
||||
<hr className="my-7" aria-hidden />
|
||||
<TroubleshootingFilter
|
||||
keywords={keywords}
|
||||
products={products}
|
||||
errors={errors}
|
||||
className="mb-8"
|
||||
/>
|
||||
<TroubleshootingListController />
|
||||
<TroubleshootingFilterEmptyState />
|
||||
<div id={TROUBLESHOOTING_CONTAINER_ID}>
|
||||
<h2 className="sr-only">Matching troubleshooting entries</h2>
|
||||
<ul>
|
||||
{troubleshootingEntries.map((entry) => (
|
||||
<li key={entry.data.database_id}>
|
||||
<TroubleshootingPreview entry={entry} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarSkeleton>
|
||||
)
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Supabase Docs | Troubleshooting',
|
||||
alternates: {
|
||||
canonical: `${PROD_URL}/guides/troubleshooting`,
|
||||
},
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import React, { Fragment } from 'react'
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import React, { Fragment, Suspense } from 'react'
|
||||
|
||||
import { useBreakpoint } from 'common'
|
||||
import {
|
||||
@@ -29,19 +29,36 @@ import {
|
||||
import * as NavItems from '~/components/Navigation/NavigationMenu/NavigationMenu.constants'
|
||||
import { getMenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu.utils'
|
||||
|
||||
const Breadcrumbs = ({ className }: { className?: string }) => {
|
||||
const pathname = usePathname()
|
||||
const menuId = getMenuId(pathname)
|
||||
const menu = NavItems[menuId]
|
||||
const breadcrumbs = findMenuItemByUrl(menu, pathname, [])
|
||||
interface BreadcrumbsProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
minLength?: number
|
||||
forceDisplayOnMobile?: boolean
|
||||
}
|
||||
|
||||
export default function Breadcrumbs(props: BreadcrumbsProps) {
|
||||
return (
|
||||
<Suspense>
|
||||
<BreadcrumbsInternal {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const BreadcrumbsInternal = ({
|
||||
className,
|
||||
minLength = 2,
|
||||
forceDisplayOnMobile = false,
|
||||
}: BreadcrumbsProps) => {
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const isMobile = useBreakpoint('md')
|
||||
|
||||
const ITEMS_TO_DISPLAY = isMobile ? 4 : 3
|
||||
|
||||
if (!breadcrumbs?.length || breadcrumbs?.length === 1) return null
|
||||
if (!breadcrumbs?.length || breadcrumbs?.length < minLength) return null
|
||||
|
||||
const appendedBreadcrumbs = breadcrumbs?.slice(-ITEMS_TO_DISPLAY + 1, isMobile ? -1 : undefined)
|
||||
const appendedBreadcrumbs = breadcrumbs?.slice(
|
||||
-ITEMS_TO_DISPLAY + 1,
|
||||
isMobile && !forceDisplayOnMobile ? -1 : undefined
|
||||
)
|
||||
|
||||
return (
|
||||
<Breadcrumb className={cn(className)}>
|
||||
@@ -50,8 +67,10 @@ const Breadcrumbs = ({ className }: { className?: string }) => {
|
||||
<>
|
||||
<BreadcrumbItem>
|
||||
{breadcrumbs[0].url ? (
|
||||
<BreadcrumbLink href={`/docs${breadcrumbs[0].url}`}>
|
||||
{breadcrumbs[0].title || breadcrumbs[0].name}
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={breadcrumbs[0].url}>
|
||||
{breadcrumbs[0].title || breadcrumbs[0].name}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbPage>{breadcrumbs[0].title || breadcrumbs[0].name}</BreadcrumbPage>
|
||||
@@ -75,7 +94,7 @@ const Breadcrumbs = ({ className }: { className?: string }) => {
|
||||
className={cn(!crumb.url && 'pointer-events-none')}
|
||||
>
|
||||
{crumb.url ? (
|
||||
<Link href={`/docs${crumb.url}`}>{crumb.title || crumb.name}</Link>
|
||||
<Link href={crumb.url}>{crumb.title || crumb.name}</Link>
|
||||
) : (
|
||||
crumb.title || crumb.name
|
||||
)}
|
||||
@@ -94,7 +113,7 @@ const Breadcrumbs = ({ className }: { className?: string }) => {
|
||||
.slice(1, -2)
|
||||
.map((crumb) =>
|
||||
crumb.url ? (
|
||||
<Link href={`/docs${crumb.url}`}>{crumb.title || crumb.name}</Link>
|
||||
<Link href={crumb.url}>{crumb.title || crumb.name}</Link>
|
||||
) : (
|
||||
crumb.title || crumb.name
|
||||
)
|
||||
@@ -121,8 +140,8 @@ const Breadcrumbs = ({ className }: { className?: string }) => {
|
||||
)}
|
||||
>
|
||||
{crumb.url ? (
|
||||
<BreadcrumbLink href={`/docs${crumb.url}`}>
|
||||
{crumb.title || crumb.name}
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={crumb.url}>{crumb.title || crumb.name}</Link>
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbPage>{crumb.title || crumb.name}</BreadcrumbPage>
|
||||
@@ -138,6 +157,20 @@ const Breadcrumbs = ({ className }: { className?: string }) => {
|
||||
)
|
||||
}
|
||||
|
||||
function useBreadcrumbs() {
|
||||
const pathname = usePathname()
|
||||
|
||||
const isTroubleshootingPage = pathname.startsWith('/guides/troubleshooting')
|
||||
if (isTroubleshootingPage) {
|
||||
const breadcrumbs = [{ name: 'Troubleshooting', url: '/guides/troubleshooting' }]
|
||||
return breadcrumbs
|
||||
}
|
||||
|
||||
const menuId = getMenuId(pathname)
|
||||
const menu = NavItems[menuId]
|
||||
return findMenuItemByUrl(menu, pathname, [])
|
||||
}
|
||||
|
||||
function findMenuItemByUrl(menu, targetUrl, parents = []) {
|
||||
// If the menu has items, recursively search through them
|
||||
if (menu.items) {
|
||||
@@ -157,5 +190,3 @@ function findMenuItemByUrl(menu, targetUrl, parents = []) {
|
||||
// If the URL is not found, return null
|
||||
return null
|
||||
}
|
||||
|
||||
export default Breadcrumbs
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useSupabaseClient } from '@supabase/auth-helpers-react'
|
||||
import { Check, MessageSquareQuote, X } from 'lucide-react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
@@ -69,7 +71,7 @@ function reducer(state: State, action: Action) {
|
||||
}
|
||||
}
|
||||
|
||||
function Feedback() {
|
||||
function Feedback({ className }: { className?: string }) {
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const feedbackButtonRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -132,7 +134,7 @@ function Feedback() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="@container px-5 mb-6" aria-labelledby="feedback-title">
|
||||
<section className={cn('@container px-5 mb-6', className)} aria-labelledby="feedback-title">
|
||||
<h3 id="feedback-title" className="block font-mono text-xs uppercase text-foreground mb-4">
|
||||
Is this helpful?
|
||||
</h3>
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronsUpDown } from 'lucide-react'
|
||||
import {
|
||||
createContext,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useId,
|
||||
useState,
|
||||
PropsWithChildren,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
|
||||
import {
|
||||
cn,
|
||||
Badge,
|
||||
Checkbox_Shadcn_ as Checkbox,
|
||||
Popover_Shadcn_ as Popover,
|
||||
PopoverContent_Shadcn_ as PopoverContent,
|
||||
PopoverTrigger_Shadcn_ as PopoverTrigger,
|
||||
} from 'ui'
|
||||
|
||||
interface Item {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const MultiSelectContext = createContext<{
|
||||
selected: Item[]
|
||||
handleSelect: (item: Item) => void
|
||||
setOpen: (open: boolean) => void
|
||||
}>({
|
||||
selected: [],
|
||||
handleSelect: () => {},
|
||||
setOpen: () => {},
|
||||
})
|
||||
|
||||
function MultiSelectProvider({
|
||||
selected,
|
||||
onSelectedChange,
|
||||
setOpen,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
selected: Item[]
|
||||
onSelectedChange: (selected: Item[] | ((selected: Item[]) => Item[])) => void
|
||||
setOpen: (open: boolean) => void
|
||||
}>) {
|
||||
const handleSelect = useCallback(
|
||||
(item: Item) => {
|
||||
onSelectedChange((current: Item[]) => {
|
||||
const isSelected = current.some((currItem) => currItem.value === item.value)
|
||||
if (isSelected) {
|
||||
return current.filter((currItem) => currItem.value !== item.value)
|
||||
} else {
|
||||
return [...current, item]
|
||||
}
|
||||
})
|
||||
},
|
||||
[onSelectedChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<MultiSelectContext.Provider value={{ selected, handleSelect, setOpen }}>
|
||||
{children}
|
||||
</MultiSelectContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function useMultiSelect() {
|
||||
const context = useContext(MultiSelectContext)
|
||||
if (!context) {
|
||||
throw new Error('useMultiSelect must be used within a MultiSelectProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
open: _controlledOpen,
|
||||
setOpen: _setControlledOpen,
|
||||
selected,
|
||||
onSelectedChange,
|
||||
children,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
{
|
||||
open?: boolean
|
||||
setOpen?: React.Dispatch<React.SetStateAction<boolean>>
|
||||
selected?: Item[]
|
||||
onSelectedChange: (selected: Item[] | ((selected: Item[]) => Item[])) => void
|
||||
} & React.ComponentProps<typeof Popover>
|
||||
>) {
|
||||
const [_internalOpen, _setInternalOpen] = useState(false)
|
||||
const open = _controlledOpen ?? _internalOpen
|
||||
const setOpen = _setControlledOpen ?? _setInternalOpen
|
||||
|
||||
return (
|
||||
<MultiSelectProvider selected={selected} onSelectedChange={onSelectedChange} setOpen={setOpen}>
|
||||
<Popover open={open} onOpenChange={setOpen} {...props}>
|
||||
{children}
|
||||
</Popover>
|
||||
</MultiSelectProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const Trigger = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{ label?: string; className?: string } & React.ComponentProps<typeof PopoverTrigger>
|
||||
>(({ label, className, ...props }, ref) => {
|
||||
const { selected } = useMultiSelect()
|
||||
|
||||
const [measuredWidth, setMeasuredWidth] = useState(0)
|
||||
const measuredRef = (node: HTMLElement) => {
|
||||
if (node) {
|
||||
setMeasuredWidth(node.getBoundingClientRect().width)
|
||||
}
|
||||
}
|
||||
|
||||
const badgeWidth = 100 // Approximate width of a badge in pixels
|
||||
const maxBadges = Math.max(1, Math.floor((measuredWidth || 200) / badgeWidth))
|
||||
const visibleBadges = selected.slice(0, maxBadges)
|
||||
const extraBadgesCount = Math.max(0, selected.length - maxBadges)
|
||||
|
||||
const badgeClasses = 'bg-200'
|
||||
|
||||
return (
|
||||
<PopoverTrigger asChild ref={ref}>
|
||||
<button
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'flex w-full min-w-[200px] items-center justify-between rounded-md border',
|
||||
'border-alternative bg-foreground/[.026] px-3 py-2 text-sm',
|
||||
'ring-offset-background placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'hover:border-primary transition-colors duration-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{selected.length === 0 ? (
|
||||
// Leading prevents shift when switching to badges
|
||||
<span className="text-muted-foreground leading-[1.375rem]">{label}</span>
|
||||
) : (
|
||||
<div ref={measuredRef} className="flex gap-1 overflow-hidden">
|
||||
{visibleBadges.map((item) => (
|
||||
<Badge key={item.value} className={cn('shrink-0', badgeClasses)}>
|
||||
{item.label}
|
||||
</Badge>
|
||||
))}
|
||||
{extraBadgesCount > 0 && <Badge className={badgeClasses}>+{extraBadgesCount}</Badge>}
|
||||
</div>
|
||||
)}
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
)
|
||||
})
|
||||
Trigger.displayName = 'Trigger'
|
||||
MultiSelect.Trigger = Trigger
|
||||
|
||||
const Content = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<{ className?: string } & React.ComponentProps<typeof PopoverContent>>
|
||||
>(({ children, className, ...props }, ref) => {
|
||||
return (
|
||||
<PopoverContent ref={ref} className={cn('w-full p-4 space-y-4', className)} {...props}>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
)
|
||||
})
|
||||
Content.displayName = 'Content'
|
||||
MultiSelect.Content = Content
|
||||
|
||||
export function Item({ item, className }: { item: Item; className?: string }) {
|
||||
const id = useId()
|
||||
const { selected, handleSelect } = useMultiSelect()
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<Checkbox
|
||||
id={`${id}-checkbox-${item.value}`}
|
||||
checked={selected.some((sel) => sel.value === item.value)}
|
||||
onCheckedChange={() => handleSelect(item)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`${id}-checkbox-${item.value}`}
|
||||
className="text-sm leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{item.label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
MultiSelect.Item = Item
|
||||
@@ -1,10 +1,10 @@
|
||||
import Link from 'next/link'
|
||||
import { Button } from 'ui'
|
||||
import { Button, cn } from 'ui'
|
||||
import { primaryLinks, secondaryLinks } from '~/data/footer'
|
||||
import { LayoutMainContent } from '~/layouts/DefaultLayout'
|
||||
|
||||
const Footer = () => (
|
||||
<LayoutMainContent className="pt-0">
|
||||
const Footer = ({ className }: { className?: string }) => (
|
||||
<LayoutMainContent className={cn('pt-0', className)}>
|
||||
<footer role="contentinfo" aria-label="footer">
|
||||
<div className="mt-16">
|
||||
<ul className="flex flex-col gap-2">
|
||||
|
||||
@@ -172,6 +172,11 @@ export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [
|
||||
label: 'Resources',
|
||||
menuItems: [
|
||||
[
|
||||
/* {
|
||||
label: 'Troubleshooting',
|
||||
icon: 'contributing',
|
||||
href: '/guides/troubleshooting',
|
||||
}, */
|
||||
{
|
||||
label: 'Migration guides',
|
||||
icon: 'resources',
|
||||
|
||||
@@ -30,12 +30,12 @@ cli = ["supabase-migration-repair"]
|
||||
|
||||
# Optionally, list the error messages associated with this issue.
|
||||
[[errors]]
|
||||
httpStatusCode = 404
|
||||
http_status_code = 404
|
||||
code = "not_found"
|
||||
message = "The resource was not found"
|
||||
|
||||
[[errors]]
|
||||
httpStatusCode = 403
|
||||
http_status_code = 403
|
||||
code = "forbidden"
|
||||
message = "Not allowed to access this resource"
|
||||
---
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title = "How to monitor Postgres and Supavisor connections"
|
||||
topics = [
|
||||
"database",
|
||||
"supavisor",
|
||||
]
|
||||
keywords = [
|
||||
"Grafana",
|
||||
"connections",
|
||||
"performance",
|
||||
"pooler",
|
||||
]
|
||||
---
|
||||
|
||||
This guide explains how connections impact your Supabase database's performance and how to optimize them for better resource utilization.
|
||||
|
||||
## Installing Supabase Grafana
|
||||
|
||||
Supabase has an [open-source Grafana Repo](https://github.com/supabase/supabase-grafana) that displays real-time metrics of your database. Although the [Reports Dashboard ](/dashboard/project/_/reports) provides similar metrics, it averages the data by the hour or day. Having visuals of your connection usage can help you better allocate resources.
|
||||
|
||||
_Visual of Grafana Dashboard_
|
||||

|
||||
|
||||
It can be run locally within Docker. Alternatively, you can deploy it to fly.io or Grafana Cloud, which are better for long-term data collection.
|
||||
|
||||
Installation instructions can be found in it the [metrics docs ](/docs/guides/platform/metrics#deploying-supabase-grafana)
|
||||
|
||||
## Observing Connections
|
||||
|
||||
In Supabase Grafana, the "Client Connections" graph shows connections to both Supavisor and Postgres
|
||||

|
||||
|
||||
- **Yellow**: The yellow line represents the number of actively querying and idle connections to the Supavisor Pooler.
|
||||
- **Green**: The green line represents the total number of actively querying and idle direct connections to the database.
|
||||
|
||||
## Investigating Connection Sources
|
||||
|
||||
`pg_stat_activity` is a `VIEW` that keeps track of processes being run by your database, including connections. It's particularly useful for determining if idle clients are hogging connection slots.
|
||||
|
||||
This is a query you can use to observe the database roles and servers connecting to your database:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
pg_stat_activity.pid,
|
||||
ssl AS ssl_connection,
|
||||
datname AS database,
|
||||
usename AS connected_role,
|
||||
application_name,
|
||||
client_addr,
|
||||
query,
|
||||
query_start,
|
||||
state,
|
||||
backend_start
|
||||
FROM pg_stat_ssl
|
||||
JOIN pg_stat_activity
|
||||
ON pg_stat_ssl.pid = pg_stat_activity.pid;
|
||||
```
|
||||
|
||||
Interpreting the query:
|
||||
|
||||
| Column | Description |
|
||||
| ---------------- | ------------------------------------------------- |
|
||||
| pid | connection id |
|
||||
| ssl | Indicates if SSL is in use |
|
||||
| datname | Name of the connected database (usually postgres) |
|
||||
| usename | Role of the connected user |
|
||||
| application_name | Name of the connecting application |
|
||||
| client_addr | IP address of the connecting server |
|
||||
| query | Last query executed by the connection |
|
||||
| query_start | Time when the last query was executed |
|
||||
| state | Querying state: active or idle |
|
||||
| backend_start | Timestamp of the connection's establishment |
|
||||
|
||||
- Note: If you are unfamiliar with the Supabase database roles, check this [reference](https://gist.github.com/TheOtherBrian1/d6e862a65e03049eb4f102f6ca809401)
|
||||
|
||||
If you believe a connection should be killed, you can do so by running the following query:
|
||||
|
||||
```sql
|
||||
select pg_terminate_backend(pid)
|
||||
from pg_stat_activity
|
||||
where pid = <connection_id>;
|
||||
```
|
||||
|
||||
## Managing the Supavisor Pooler:
|
||||
|
||||
The Supavisor Pooler is an intermediary between your clients (application servers) and the database. In transaction mode (port 6543), it can enable Postgres to share single connections with many clients, only allowing access when a query is pending. This prevents idle clients from hogging a direct connection and allows for more throughput.
|
||||
|
||||
In cases where you see significantly more pooler connections than direct connections, if you can, you should consider increasing how many direct connections the pooler is allowed to manage in the [Dashboard's Database Settings](/dashboard/project/_/settings/database):
|
||||
.
|
||||
|
||||
The general rule is that if you are using the PostgREST database API, you should avoid raising your pool size past 40%. Otherwise, you can commit 80% to the pool. This leaves adequate room for the Authentication server and other utilities.
|
||||
|
||||
These numbers are generalizations and assume a certain level of activity from all connected servers. The actual values depend on your peak connection usage. For instance, if you were only using 80 connections in a week period and your database could support 500 connections, then realistically you could allocate the remaining 420 (minus a reasonable buffer) to service more demand.
|
||||
|
||||
## Secondary Issues:
|
||||
|
||||
When managing Postgres, outside of connections, there are generally 3 likely bottlenecks (links to address each):
|
||||
|
||||
- [Disk/IO](https://github.com/orgs/supabase/discussions/27003)
|
||||
- [Memory](https://github.com/orgs/supabase/discussions/27021)
|
||||
- [CPU](https://github.com/orgs/supabase/discussions/27022)
|
||||
|
||||
They're all intertwined to some extent. If IO, CPU, or Memory are constrained, this can cause queries to slow down. Your application servers and Supavisor may compensate by creating more database connections or letting queries wait longer in their respective queues. Sometimes, by addressing or optimizing other factors of the database, you can better address connection issues.
|
||||
|
||||
## Other helpful resources:
|
||||
|
||||
- [Supavisor FAQ](https://github.com/orgs/supabase/discussions/21566)
|
||||
- [Using SQLAlchemy with Supabase](https://github.com/orgs/supabase/discussions/27071)
|
||||
- [Supabase and IPv4/IPv6 compatibility ](https://github.com/orgs/supabase/discussions/27034)
|
||||
- [Addressing Max Client Errors](https://github.com/orgs/supabase/discussions/22305)
|
||||
- [Connecting to your database](/docs/guides/database/connecting-to-postgres#integrations)
|
||||
- [How to Change Max Database Connections](https://github.com/orgs/supabase/discussions/27197)
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title = "Interpreting Supabase Grafana Memory Charts"
|
||||
topics = [
|
||||
"database",
|
||||
]
|
||||
keywords = [
|
||||
"Grafana",
|
||||
"performance",
|
||||
"memory"
|
||||
]
|
||||
---
|
||||
|
||||
> [Supabase Grafana Installation Guide](/docs/guides/platform/metrics#deploying-supabase-grafana)
|
||||
|
||||
Here are examples of unhealthy memory usage:
|
||||

|
||||

|
||||
|
||||
- **Yellow**: represents active memory
|
||||
- **Red**: represents SWAP, which is disk storage that the system treats as if it were actually memory
|
||||
- **Green**: it is unclaimed (the system will always leave some memory unclaimed)
|
||||
- **Blue**: it is cached data and a buffer
|
||||
|
||||
The cache in PostgreSQL is important because the database will store frequently accessed data in it for rapid retrieval. If too much active memory is needed, it runs the risk of excessively displacing cache. This will force queries to check disk, which is slow.
|
||||
|
||||
Most data in a database is idle, but in cases where there is little available memory or uncached data is rapidly accessed, [thrashing](<https://en.wikipedia.org/wiki/Thrashing_(computer_science)>) can occur.
|
||||
|
||||
Ideally, you want queries to hit the cache 99% of the time. You can use the [Supabase CLI](/docs/guides/database/inspect) `inspect db cache hit` command to check your cache hit rate. Alternatively, you can run the [query](https://github.com/supabase/cli/blob/c9cce58025fded16b4c332747f819a44f45c3b83/internal/inspect/bloat/bloat.go#L17) found in the CLI's GitHub repo in the [SQL Editor](/dashboard/project/_/sql/)
|
||||
|
||||
```sh
|
||||
# login to the CLI
|
||||
npx supabase login
|
||||
|
||||
# initlize a local supabase directory
|
||||
npx supabase init
|
||||
|
||||
#link your project
|
||||
npx supabase link
|
||||
|
||||
# find cache hit rate
|
||||
npx supabase inspect db cache-hit --linked
|
||||
|
||||
```
|
||||
|
||||
If the cache hit rate begins to drop below the ideal amount, one should consider taking the following actions:
|
||||
|
||||
Optimizing:
|
||||
|
||||
1. [Apply indexes](https://github.com/orgs/supabase/discussions/22449): can reduce the amount of data pulled from disk into memory
|
||||
2. [Increasing the compute size](/docs/guides/platform/compute-add-ons)
|
||||
3. [Distribute load by using read-replicas](/dashboard/project/_/settings/infrastructure)
|
||||
4. [Partitions](/docs/guides/database/partitions): Generally should be used on _very_ large tables to minimize data pulled from disk into memory
|
||||
5. [Remove bloat](/docs/guides/database/bloat): Bloat can fragment data across pages, causing redundant data to be pulled from disk.
|
||||
6. Table refactoring: Split tables to isolate columns that are less frequently accessed, so they are not redundantly pulled into memory while accessing hotter data
|
||||
|
||||
### Other useful Supabase Grafana guides:
|
||||
|
||||
- [Connections](https://github.com/orgs/supabase/discussions/27141)
|
||||
- [Disk](https://github.com/orgs/supabase/discussions/27003)
|
||||
- [CPU](https://github.com/orgs/supabase/discussions/27022)
|
||||
@@ -0,0 +1,103 @@
|
||||
import { cn } from 'ui'
|
||||
|
||||
import Breadcrumbs from '~/components/Breadcrumbs'
|
||||
import { Feedback } from '~/components/Feedback'
|
||||
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
|
||||
import { MDXRemoteBase } from './MdxBase'
|
||||
import { type ITroubleshootingEntry } from './Troubleshooting.utils'
|
||||
import Link from 'next/link'
|
||||
import { formatError, serializeTroubleshootingSearchParams } from './Troubleshooting.utils.shared'
|
||||
|
||||
export default function TroubleshootingPage({ entry }: { entry: ITroubleshootingEntry }) {
|
||||
return (
|
||||
<SidebarSkeleton className="@container/troubleshooting-entry-layout w-full max-w-screen-lg mx-auto lg:py-8 lg:px-5">
|
||||
<div className="px-5 py-8 lg:px-0 lg:py-0">
|
||||
<Breadcrumbs minLength={1} forceDisplayOnMobile />
|
||||
<article className="prose max-w-none mt-4">
|
||||
<h1>{entry.data.title}</h1>
|
||||
{entry.data.updated_at && <p>Last edited: {entry.data.updated_at.toLocaleString()}</p>}
|
||||
<hr className="my-7" aria-hidden />
|
||||
<div className="grid gap-10 @3xl/troubleshooting-entry-layout:grid-cols-[1fr,250px]">
|
||||
<div>
|
||||
<MDXRemoteBase source={entry.content} />
|
||||
</div>
|
||||
<aside aria-labelledby="heading--metadata" className="not-prose mt-5">
|
||||
<h2
|
||||
id="heading--metadata"
|
||||
className="text-foreground-lighter uppercase text-sm tracking-wide @3xl/troubleshooting-entry-layout:sr-only"
|
||||
>
|
||||
Metadata
|
||||
</h2>
|
||||
<hr className="my-6 @3xl/troubleshooting-entry-layout:hidden" aria-hidden />
|
||||
{entry.data.topics?.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-sm text-foreground-lighter mb-3">Products</h3>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{entry.data.topics.map((topic) => (
|
||||
<Link
|
||||
key={topic}
|
||||
href={`/guides/troubleshooting${serializeTroubleshootingSearchParams({ products: [topic] })}`}
|
||||
>
|
||||
<PillTag className="hover:bg-200 focus-visible:bg-foreground-muted hover:border-control focus-visible:border-control transition-colors">
|
||||
{topic[0].toUpperCase() + topic.slice(1)}
|
||||
</PillTag>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<hr className="my-6" aria-hidden />
|
||||
</>
|
||||
)}
|
||||
{entry.data.errors?.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-sm text-foreground-lighter mb-3">Related error codes</h3>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{entry.data.errors.map((error, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={`/guides/troubleshooting${serializeTroubleshootingSearchParams({ errorCodes: [formatError(error)] })}`}
|
||||
>
|
||||
<PillTag className="hover:bg-200 focus-visible:bg-foreground-muted hover:border-control focus-visible:border-control transition-colors">
|
||||
{formatError(error)}
|
||||
</PillTag>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<hr className="my-6" aria-hidden />
|
||||
</>
|
||||
)}
|
||||
{entry.data.keywords?.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-sm text-foreground-lighter mb-3">Tags</h3>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{entry.data.keywords.map((keyword) => (
|
||||
<Link
|
||||
key={keyword}
|
||||
href={`/guides/troubleshooting${serializeTroubleshootingSearchParams({ tags: [keyword] })}`}
|
||||
>
|
||||
<PillTag className="hover:bg-200 focus-visible:bg-foreground-muted hover:border-control focus-visible:border-control transition-colors">
|
||||
{keyword}
|
||||
</PillTag>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<hr className="my-6" aria-hidden />
|
||||
</>
|
||||
)}
|
||||
<Feedback className="px-0" />
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</SidebarSkeleton>
|
||||
)
|
||||
}
|
||||
|
||||
function PillTag({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn('px-2 py-px border rounded-full inline-flex items-center text-xs', className)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown, RotateCw, Search, X } from 'lucide-react'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import { useEffect, useRef, useState, Suspense, useCallback } from 'react'
|
||||
|
||||
import {
|
||||
Input_Shadcn_,
|
||||
cn,
|
||||
Button_Shadcn_,
|
||||
Collapsible_Shadcn_ as Collapsible,
|
||||
CollapsibleTrigger_Shadcn_ as CollapsibleTrigger,
|
||||
CollapsibleContent_Shadcn_ as CollapsibleContent,
|
||||
} from 'ui'
|
||||
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { MultiSelect } from '~/components/MultiSelect.client'
|
||||
import { type ITroubleshootingMetadata } from './Troubleshooting.utils'
|
||||
import {
|
||||
formatError,
|
||||
TROUBLESHOOTING_CONTAINER_ID,
|
||||
TROUBLESHOOTING_DATA_ATTRIBUTES,
|
||||
troubleshootingSearchParams,
|
||||
} from './Troubleshooting.utils.shared'
|
||||
import { useBreakpoint } from 'common'
|
||||
|
||||
function useTroubleshootingSearchState() {
|
||||
const [_state, _setState] = useQueryStates(troubleshootingSearchParams)
|
||||
|
||||
const setSelectedProducts = useCallback(
|
||||
(products: string[]) => {
|
||||
_setState({
|
||||
products: products.length === 0 ? null : products,
|
||||
})
|
||||
},
|
||||
[_setState]
|
||||
)
|
||||
const setSelectedErrorCodes = useCallback(
|
||||
(errorCodes: string[]) => {
|
||||
_setState({
|
||||
errorCodes: errorCodes.length === 0 ? null : errorCodes,
|
||||
})
|
||||
},
|
||||
[_setState]
|
||||
)
|
||||
const setSelectedTags = useCallback(
|
||||
(tags: string[]) => {
|
||||
_setState({
|
||||
tags: tags.length === 0 ? null : tags,
|
||||
})
|
||||
},
|
||||
[_setState]
|
||||
)
|
||||
const setSearchState = useCallback(
|
||||
(search: string) => {
|
||||
_setState({
|
||||
search: search.length === 0 ? null : search,
|
||||
})
|
||||
},
|
||||
[_setState]
|
||||
)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setSearchState('')
|
||||
setSelectedTags([])
|
||||
setSelectedProducts([])
|
||||
setSelectedErrorCodes([])
|
||||
}, [setSearchState, setSelectedTags, setSelectedProducts, setSelectedErrorCodes])
|
||||
|
||||
return {
|
||||
selectedProducts: _state.products,
|
||||
selectedErrorCodes: _state.errorCodes,
|
||||
selectedTags: _state.tags,
|
||||
searchState: _state.search,
|
||||
setSelectedProducts,
|
||||
setSelectedErrorCodes,
|
||||
setSelectedTags,
|
||||
setSearchState,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
function entryMatchesFilter(
|
||||
entry: HTMLElement,
|
||||
selectedProducts: string[],
|
||||
selectedErrorCodes: string[],
|
||||
selectedTags: string[],
|
||||
searchState: string
|
||||
) {
|
||||
const content = entry.textContent ?? ''
|
||||
const dataKeywords = entry.getAttribute(TROUBLESHOOTING_DATA_ATTRIBUTES.KEYWORDS_LIST_ATTRIBUTE)
|
||||
const dataProducts = entry.getAttribute(TROUBLESHOOTING_DATA_ATTRIBUTES.PRODUCTS_LIST_ATTRIBUTE)
|
||||
const dataErrors = entry.getAttribute('data-errors')?.split(',') ?? []
|
||||
|
||||
const productsMatch =
|
||||
selectedProducts.length === 0 ||
|
||||
selectedProducts.some((product) => dataProducts?.includes(product))
|
||||
const tagsMatch =
|
||||
selectedTags.length === 0 || selectedTags.some((tag) => dataKeywords?.includes(tag))
|
||||
const errorsMatch =
|
||||
selectedErrorCodes.length === 0 ||
|
||||
selectedErrorCodes.some((error) => dataErrors.includes(error))
|
||||
const searchMatch =
|
||||
searchState === '' || content.toLowerCase().includes(searchState.toLowerCase())
|
||||
|
||||
return productsMatch && errorsMatch && tagsMatch && searchMatch
|
||||
}
|
||||
|
||||
interface TroubleshootingFilterProps {
|
||||
products: string[]
|
||||
errors: ITroubleshootingMetadata['errors']
|
||||
keywords: string[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TroubleshootingFilter(props: TroubleshootingFilterProps) {
|
||||
return (
|
||||
<Suspense fallback={<ShimmeringLoader className="h-7 py-0" />}>
|
||||
<TroubleshootingFilterMobileCollapsed {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function TroubleshootingFilterMobileCollapsed(props: TroubleshootingFilterProps) {
|
||||
const isBelowSmallScreen = useBreakpoint('sm')
|
||||
const { selectedProducts, selectedErrorCodes, selectedTags, searchState } =
|
||||
useTroubleshootingSearchState()
|
||||
|
||||
const numberFiltersApplied =
|
||||
(selectedProducts.length > 0 ? 1 : 0) +
|
||||
(selectedErrorCodes.length > 0 ? 1 : 0) +
|
||||
(selectedTags.length > 0 ? 1 : 0) +
|
||||
(searchState ? 1 : 0)
|
||||
|
||||
if (isBelowSmallScreen) {
|
||||
return (
|
||||
<Collapsible className="border-b">
|
||||
<CollapsibleTrigger className="group w-full pb-6 text-foreground-light">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span>Filters</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="group-data-[state=open]:rotate-180 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
{numberFiltersApplied > 0 && (
|
||||
<div className="group-data-[state=open]:hidden text-sm text-left">
|
||||
{numberFiltersApplied} filter{numberFiltersApplied > 1 ? 's' : ''} applied
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="flex flex-col gap-2">
|
||||
<TroubleshootingFilterInternal {...props} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
return <TroubleshootingFilterInternal {...props} />
|
||||
}
|
||||
|
||||
function TroubleshootingFilterInternal({
|
||||
keywords,
|
||||
products,
|
||||
errors,
|
||||
className,
|
||||
}: TroubleshootingFilterProps) {
|
||||
const {
|
||||
selectedProducts,
|
||||
selectedErrorCodes,
|
||||
selectedTags,
|
||||
searchState,
|
||||
setSelectedProducts,
|
||||
setSelectedErrorCodes,
|
||||
setSelectedTags,
|
||||
setSearchState,
|
||||
reset,
|
||||
} = useTroubleshootingSearchState()
|
||||
|
||||
const [productsOpen, _setProductsOpen] = useState(false)
|
||||
const [errorsOpen, _setErrorsOpen] = useState(false)
|
||||
const [tagsOpen, _setTagsOpen] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const allEntries = useRef<HTMLElement[]>([])
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(TROUBLESHOOTING_CONTAINER_ID)
|
||||
if (!container) return
|
||||
|
||||
const entries = Array.from(
|
||||
container.querySelectorAll(
|
||||
`[${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_ATTRIBUTE}="${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_VALUE_ENTRY}"]`
|
||||
)
|
||||
) as HTMLElement[]
|
||||
allEntries.current = entries
|
||||
}, [])
|
||||
|
||||
const setProductsOpen = useCallback(
|
||||
(open: boolean) => {
|
||||
_setProductsOpen(open)
|
||||
if (open) {
|
||||
allEntries.current?.forEach((entry) => (entry.inert = true))
|
||||
} else {
|
||||
allEntries.current?.forEach((entry) => (entry.inert = false))
|
||||
}
|
||||
},
|
||||
[_setProductsOpen]
|
||||
)
|
||||
const setErrorsOpen = useCallback(
|
||||
(open: boolean) => {
|
||||
_setErrorsOpen(open)
|
||||
if (open) {
|
||||
allEntries.current?.forEach((entry) => (entry.inert = true))
|
||||
} else {
|
||||
allEntries.current?.forEach((entry) => (entry.inert = false))
|
||||
}
|
||||
},
|
||||
[_setErrorsOpen]
|
||||
)
|
||||
const setTagsOpen = useCallback(
|
||||
(open: boolean) => {
|
||||
_setTagsOpen(open)
|
||||
if (open) {
|
||||
allEntries.current?.forEach((entry) => (entry.inert = true))
|
||||
} else {
|
||||
allEntries.current?.forEach((entry) => (entry.inert = false))
|
||||
}
|
||||
},
|
||||
[_setTagsOpen]
|
||||
)
|
||||
|
||||
const convertToItem = useCallback(
|
||||
(arr: string[], { capitalize = false }: { capitalize?: boolean } = {}) =>
|
||||
arr.map((item) => ({
|
||||
value: item,
|
||||
label: capitalize ? item[0].toUpperCase() + item.slice(1) : item,
|
||||
})),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="sr-only">Search and filter</h2>
|
||||
<div className={cn('flex flex-wrap gap-2 items-center', className)}>
|
||||
<MultiSelect
|
||||
open={productsOpen}
|
||||
onOpenChange={setProductsOpen}
|
||||
selected={convertToItem(selectedProducts, { capitalize: true })}
|
||||
onSelectedChange={(newItemsOrCreateNewItems) => {
|
||||
const newItems =
|
||||
typeof newItemsOrCreateNewItems === 'function'
|
||||
? newItemsOrCreateNewItems(convertToItem(selectedProducts, { capitalize: true }))
|
||||
: newItemsOrCreateNewItems
|
||||
setSelectedProducts(newItems.map((item) => item.value))
|
||||
}}
|
||||
>
|
||||
<MultiSelect.Trigger className="w-48" label="Products" />
|
||||
<MultiSelect.Content sameWidthAsTrigger>
|
||||
{products.map((product) => (
|
||||
<MultiSelect.Item
|
||||
item={{ value: product, label: product[0].toUpperCase() + product.slice(1) }}
|
||||
/>
|
||||
))}
|
||||
</MultiSelect.Content>
|
||||
</MultiSelect>
|
||||
<MultiSelect
|
||||
open={errorsOpen}
|
||||
onOpenChange={setErrorsOpen}
|
||||
selected={convertToItem(selectedErrorCodes, { capitalize: true })}
|
||||
onSelectedChange={(newItemsOrCreateNewItems) => {
|
||||
const newItems =
|
||||
typeof newItemsOrCreateNewItems === 'function'
|
||||
? newItemsOrCreateNewItems(convertToItem(selectedErrorCodes, { capitalize: true }))
|
||||
: newItemsOrCreateNewItems
|
||||
setSelectedErrorCodes(newItems.map((item) => item.value))
|
||||
}}
|
||||
>
|
||||
<MultiSelect.Trigger className="w-48" label="Error codes" />
|
||||
<MultiSelect.Content sameWidthAsTrigger>
|
||||
{errors.map((error) => (
|
||||
<MultiSelect.Item item={{ value: formatError(error), label: formatError(error) }} />
|
||||
))}
|
||||
</MultiSelect.Content>
|
||||
</MultiSelect>
|
||||
<MultiSelect
|
||||
open={tagsOpen}
|
||||
onOpenChange={setTagsOpen}
|
||||
selected={convertToItem(selectedTags, { capitalize: true })}
|
||||
onSelectedChange={(newItemsOrCreateNewItems) => {
|
||||
const newItems =
|
||||
typeof newItemsOrCreateNewItems === 'function'
|
||||
? newItemsOrCreateNewItems(convertToItem(selectedTags, { capitalize: true }))
|
||||
: newItemsOrCreateNewItems
|
||||
setSelectedTags(newItems.map((item) => item.value))
|
||||
}}
|
||||
>
|
||||
<MultiSelect.Trigger className="w-48" label="Tags" />
|
||||
<MultiSelect.Content sameWidthAsTrigger>
|
||||
{keywords.map((keyword) => (
|
||||
<MultiSelect.Item item={{ value: keyword, label: keyword }} />
|
||||
))}
|
||||
</MultiSelect.Content>
|
||||
</MultiSelect>
|
||||
<div className="relative">
|
||||
<Input_Shadcn_
|
||||
id="troubleshooting-search"
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search by keyword"
|
||||
className="pl-8 pr-8 h-[40px] w-60 rounded-md border-alternative placeholder:text-foreground-light"
|
||||
value={searchState}
|
||||
onChange={(e) => setSearchState(e.target.value)}
|
||||
/>
|
||||
<Search
|
||||
aria-hidden
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 text-foreground-light"
|
||||
size={16}
|
||||
/>
|
||||
{searchState && (
|
||||
<button
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-foreground-light border-alternative hover:border-stronger rounded-md p-1 transition-colors"
|
||||
onClick={() => {
|
||||
setSearchState('')
|
||||
searchInputRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Clear search</span>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button_Shadcn_
|
||||
variant="outline"
|
||||
className="rounded-md text-foreground-light h-[40px] w-[40px] p-0"
|
||||
onClick={reset}
|
||||
>
|
||||
<RotateCw size={16} />
|
||||
<span className="sr-only">Reset filters</span>
|
||||
</Button_Shadcn_>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function TroubleshootingFilterEmptyState() {
|
||||
return (
|
||||
<Suspense>
|
||||
<TroubleshootingFilterEmptyStateInternal />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function TroubleshootingFilterEmptyStateInternal() {
|
||||
const allEntries = useRef<HTMLElement[] | undefined>(undefined)
|
||||
const { selectedProducts, selectedErrorCodes, selectedTags, searchState, reset } =
|
||||
useTroubleshootingSearchState()
|
||||
|
||||
const [numberResults, setNumberResults] = useState<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(TROUBLESHOOTING_CONTAINER_ID)
|
||||
if (!container) return
|
||||
|
||||
const entries = Array.from(
|
||||
container.querySelectorAll(
|
||||
`[${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_ATTRIBUTE}="${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_VALUE_ENTRY}"]`
|
||||
)
|
||||
) as HTMLElement[]
|
||||
|
||||
allEntries.current = entries
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!allEntries.current) return
|
||||
|
||||
const numberEntries = allEntries.current.filter((entry) => !entry.hidden).length
|
||||
setNumberResults(numberEntries)
|
||||
}, [searchState, selectedProducts, selectedErrorCodes, selectedTags])
|
||||
|
||||
return numberResults === 0 ? (
|
||||
<span className="flex items-center gap-4 text-foreground-light">
|
||||
No results found.
|
||||
<Button_Shadcn_ variant="outline" className="flex items-center gap-2" onClick={reset}>
|
||||
<RotateCw size={16} /> Reset filters
|
||||
</Button_Shadcn_>
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is used to control the visibility of the list of
|
||||
* troubleshooting entries.
|
||||
*
|
||||
* Filtering is done wth imperative DOM manipulation rather than mapping and
|
||||
* filtering the target list in React, in order to opt the full troubleshooting
|
||||
* list into server-side rendering.
|
||||
*/
|
||||
export function TroubleshootingListController() {
|
||||
return (
|
||||
<Suspense>
|
||||
<TroubleshootingListControllerInternal />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function TroubleshootingListControllerInternal() {
|
||||
const allEntries = useRef<HTMLElement[]>([])
|
||||
|
||||
const { selectedProducts, selectedErrorCodes, selectedTags, searchState } =
|
||||
useTroubleshootingSearchState()
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(TROUBLESHOOTING_CONTAINER_ID)
|
||||
if (!container) return
|
||||
|
||||
const entries = Array.from(
|
||||
container.querySelectorAll(
|
||||
`[${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_ATTRIBUTE}="${TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_VALUE_ENTRY}"]`
|
||||
)
|
||||
) as HTMLElement[]
|
||||
allEntries.current = entries
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!searchState &&
|
||||
selectedProducts.length === 0 &&
|
||||
selectedErrorCodes.length === 0 &&
|
||||
selectedTags.length === 0
|
||||
) {
|
||||
allEntries.current.forEach((entry) => {
|
||||
entry.hidden = false
|
||||
})
|
||||
} else {
|
||||
allEntries.current.forEach((entry) => {
|
||||
entry.hidden = !entryMatchesFilter(
|
||||
entry,
|
||||
selectedProducts,
|
||||
selectedErrorCodes,
|
||||
selectedTags,
|
||||
searchState
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [searchState, selectedProducts, selectedErrorCodes, selectedTags])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Wrench } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { type PropsWithChildren, useCallback } from 'react'
|
||||
|
||||
import { type ITroubleshootingEntry, getArticleSlug } from './Troubleshooting.utils'
|
||||
import { TroubleshootingFilter } from './Troubleshooting.ui.client'
|
||||
import { formatError, TROUBLESHOOTING_DATA_ATTRIBUTES } from './Troubleshooting.utils.shared'
|
||||
import { cn } from 'ui'
|
||||
|
||||
export function TroubleshootingPreview({ entry }: { entry: ITroubleshootingEntry }) {
|
||||
const keywords = [...entry.data.topics, ...(entry.data.keywords ?? [])]
|
||||
const attributes = {
|
||||
[TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_ATTRIBUTE]:
|
||||
TROUBLESHOOTING_DATA_ATTRIBUTES.QUERY_VALUE_ENTRY,
|
||||
[TROUBLESHOOTING_DATA_ATTRIBUTES.PRODUCTS_LIST_ATTRIBUTE]: entry.data.topics.join(','),
|
||||
[TROUBLESHOOTING_DATA_ATTRIBUTES.KEYWORDS_LIST_ATTRIBUTE]: keywords.join(','),
|
||||
[TROUBLESHOOTING_DATA_ATTRIBUTES.ERRORS_LIST_ATTRIBUTE]: entry.data.errors
|
||||
?.map((error) => formatError(error))
|
||||
.join(','),
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative border-b py-4"
|
||||
aria-labelledby={`troubleshooting-entry-title-${entry.data.database_id}`}
|
||||
{...attributes}
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-x-2 gap-y-4 justify-between">
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Link
|
||||
href={`/guides/troubleshooting/${getArticleSlug(entry.data)}`}
|
||||
className={cn('visited:text-foreground-lighter', 'before:absolute before:inset-0')}
|
||||
>
|
||||
<h3
|
||||
id={`troubleshooting-entry-title-${entry.data.database_id}`}
|
||||
className="text-lg truncate"
|
||||
>
|
||||
{entry.data.title}
|
||||
</h3>
|
||||
</Link>
|
||||
{entry.data.errors?.length > 0 && (
|
||||
<span className="text-xs text-foreground-lighter">
|
||||
{entry.data.errors.map((error, index) => (
|
||||
<>
|
||||
<code key={index}>{formatError(error)}</code>
|
||||
{index < entry.data.errors.length - 1 && ', '}
|
||||
</>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="basis-60 flex-shrink-0 flex-grow-0 flex flex-row gap-2">
|
||||
<div className="basis-40 flex-shrink-0 flex-grow-0 flex flex-wrap gap-0.5 text-xs">
|
||||
{entry.data.topics.map((topic) => (
|
||||
<span key={topic} className="px-2 border rounded-full inline-flex items-center">
|
||||
{topic[0].toUpperCase() + topic.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="basis-l8 flex-shrink-0 flex-grow-0 truncate text-sm text-foreground-lighter">
|
||||
{entry.data.updated_at &&
|
||||
(() => {
|
||||
const date = new Date(entry.data.updated_at)
|
||||
const options = { month: 'short', day: 'numeric' } as Intl.DateTimeFormatOptions
|
||||
if (date.getFullYear() !== new Date().getFullYear()) {
|
||||
options.year = 'numeric'
|
||||
}
|
||||
return date.toLocaleDateString(undefined, options)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createSerializer, parseAsArrayOf, parseAsString } from 'nuqs'
|
||||
|
||||
import { ITroubleshootingEntry } from './Troubleshooting.utils'
|
||||
|
||||
export const TROUBLESHOOTING_CONTAINER_ID = 'sb-docs-troubleshooting-container'
|
||||
export const TROUBLESHOOTING_DATA_ATTRIBUTES = {
|
||||
QUERY_ATTRIBUTE: 'data-sb-docs-troubleshooting',
|
||||
QUERY_VALUE_ENTRY: 'entry',
|
||||
PRODUCTS_LIST_ATTRIBUTE: 'data-products',
|
||||
KEYWORDS_LIST_ATTRIBUTE: 'data-keywords',
|
||||
ERRORS_LIST_ATTRIBUTE: 'data-errors',
|
||||
}
|
||||
|
||||
export function formatError(error: ITroubleshootingEntry['data']['errors'][number]) {
|
||||
return `${error.http_status_code ?? ''}${!!error.http_status_code && !!error.code && ' '}${error.code ?? ''}`
|
||||
}
|
||||
|
||||
export const troubleshootingSearchParams = {
|
||||
search: parseAsString.withDefault(''),
|
||||
products: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
tags: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
errorCodes: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
}
|
||||
export const serializeTroubleshootingSearchParams = createSerializer(troubleshootingSearchParams)
|
||||
@@ -1,45 +1,192 @@
|
||||
import matter from 'gray-matter'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { mdxFromMarkdown } from 'mdast-util-mdx'
|
||||
import { toMarkdown } from 'mdast-util-to-markdown'
|
||||
import { mdxjs } from 'micromark-extension-mdxjs'
|
||||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import toml from 'toml'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { z } from 'zod'
|
||||
|
||||
const TroubleshootingSchema = z.object({
|
||||
title: z.string(),
|
||||
topics: z.array(
|
||||
z.enum([
|
||||
'ai',
|
||||
'auth',
|
||||
'branching',
|
||||
'cli',
|
||||
'database',
|
||||
'functions',
|
||||
'platform',
|
||||
'realtime',
|
||||
'self-hosting',
|
||||
'storage',
|
||||
'studio',
|
||||
'supavisor',
|
||||
'terraform',
|
||||
])
|
||||
),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
api: z
|
||||
.object({
|
||||
sdk: z.array(z.string()).optional(),
|
||||
management_api: z.array(z.string()).optional(),
|
||||
cli: z.array(z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
errors: z
|
||||
.array(
|
||||
z.object({
|
||||
httpStatusCode: z.number().optional(),
|
||||
code: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
import { DOCS_DIRECTORY } from 'lib/docs'
|
||||
import { cache_fullProcess_withDevCacheBust } from '~/features/helpers.fs'
|
||||
import { formatError } from './Troubleshooting.utils.shared'
|
||||
|
||||
const TROUBLESHOOTING_DIRECTORY = join(DOCS_DIRECTORY, 'content/troubleshooting')
|
||||
|
||||
const TroubleshootingSchema = z
|
||||
.object({
|
||||
title: z.string(),
|
||||
topics: z.array(
|
||||
z.enum([
|
||||
'ai',
|
||||
'auth',
|
||||
'branching',
|
||||
'cli',
|
||||
'database',
|
||||
'functions',
|
||||
'platform',
|
||||
'realtime',
|
||||
'self-hosting',
|
||||
'storage',
|
||||
'studio',
|
||||
'supavisor',
|
||||
'terraform',
|
||||
])
|
||||
),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
api: z
|
||||
.object({
|
||||
sdk: z.array(z.string()).optional(),
|
||||
management_api: z.array(z.string()).optional(),
|
||||
cli: z.array(z.string()).optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
errors: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
http_status_code: z.number().optional(),
|
||||
code: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.optional(),
|
||||
database_id: z.string().uuid().default(uuidv4()),
|
||||
created_at: z.date({ coerce: true }).optional(),
|
||||
updated_at: z.date({ coerce: true }).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type ITroubleshootingMetadata = z.infer<typeof TroubleshootingSchema>
|
||||
|
||||
export const validateTroubleshootingMetadata = (troubleshootingMetadata: unknown) => {
|
||||
const validateTroubleshootingMetadata = (troubleshootingMetadata: unknown) => {
|
||||
return TroubleshootingSchema.safeParse(troubleshootingMetadata)
|
||||
}
|
||||
|
||||
async function getAllTroubleshootingEntriesInternal() {
|
||||
const troubleshootingDirectoryContents = await readdir(TROUBLESHOOTING_DIRECTORY, {
|
||||
recursive: true,
|
||||
})
|
||||
const troubleshootingFiles = troubleshootingDirectoryContents.map(async (entry) => {
|
||||
const isHidden = entry.startsWith('_')
|
||||
if (isHidden) return null
|
||||
|
||||
const isFile = (await stat(join(TROUBLESHOOTING_DIRECTORY, entry))).isFile()
|
||||
if (!isFile) return null
|
||||
|
||||
const fileContents = await readFile(join(TROUBLESHOOTING_DIRECTORY, entry), 'utf-8')
|
||||
const { content, data: frontmatter } = matter(fileContents, {
|
||||
language: 'toml',
|
||||
engines: { toml: toml.parse.bind(toml) },
|
||||
})
|
||||
|
||||
const parseResult = validateTroubleshootingMetadata(frontmatter)
|
||||
if ('error' in parseResult) {
|
||||
console.error(`Error validating troubleshooting metadata for ${entry}:`, parseResult.error)
|
||||
return null
|
||||
}
|
||||
|
||||
const mdxTree = fromMarkdown(content, {
|
||||
extensions: [mdxjs()],
|
||||
mdastExtensions: [mdxFromMarkdown()],
|
||||
})
|
||||
visit(mdxTree, (node) => {
|
||||
if ('children' in node) {
|
||||
node.children = node.children.filter(
|
||||
(child) =>
|
||||
![
|
||||
'mdxJsxFlowExpression',
|
||||
'mdxJsxTextExpression',
|
||||
'mdxFlowExpression',
|
||||
'mdxTextExpression',
|
||||
'mdxJsxFlowElement',
|
||||
'mdxJsxTextElement',
|
||||
'mdxJsxExpressionAttribute',
|
||||
'mdxJsxAttribute',
|
||||
'mdxJsxAttributeValueExpression',
|
||||
'mdxjsEsm',
|
||||
].includes(child.type)
|
||||
)
|
||||
}
|
||||
})
|
||||
const contentWithoutJsx = toMarkdown(mdxTree)
|
||||
|
||||
return {
|
||||
content,
|
||||
contentWithoutJsx,
|
||||
data: parseResult.data,
|
||||
}
|
||||
})
|
||||
|
||||
return (await Promise.all(troubleshootingFiles)).filter(Boolean)
|
||||
}
|
||||
|
||||
export const getAllTroubleshootingEntries = cache_fullProcess_withDevCacheBust(
|
||||
getAllTroubleshootingEntriesInternal,
|
||||
TROUBLESHOOTING_DIRECTORY,
|
||||
() => JSON.stringify([])
|
||||
)
|
||||
export type ITroubleshootingEntry = Awaited<ReturnType<typeof getAllTroubleshootingEntries>>[number]
|
||||
|
||||
export async function getAllTroubleshootingKeywords() {
|
||||
const entries = await getAllTroubleshootingEntries()
|
||||
const keywords = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
for (const topic of entry.data.topics) {
|
||||
keywords.add(topic)
|
||||
}
|
||||
for (const keyword of entry.data.keywords ?? []) {
|
||||
keywords.add(keyword)
|
||||
}
|
||||
}
|
||||
return Array.from(keywords).sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
||||
}
|
||||
|
||||
export async function getAllTroubleshootingProducts() {
|
||||
const entries = await getAllTroubleshootingEntries()
|
||||
const products = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
for (const topic of entry.data.topics) {
|
||||
products.add(topic)
|
||||
}
|
||||
}
|
||||
return Array.from(products).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
export async function getAllTroubleshootingErrors() {
|
||||
const entries = await getAllTroubleshootingEntries()
|
||||
const allErrors = new Set(
|
||||
entries
|
||||
.flatMap((entry) => entry.data.errors)
|
||||
.filter((error) => error?.http_status_code || error?.code)
|
||||
)
|
||||
|
||||
const seen = new Set<string>()
|
||||
for (const error of allErrors) {
|
||||
const key = formatError(error)
|
||||
if (seen.has(key)) {
|
||||
allErrors.delete(error)
|
||||
}
|
||||
seen.add(key)
|
||||
}
|
||||
|
||||
function sortErrors(
|
||||
a: ITroubleshootingMetadata['errors'][number],
|
||||
b: ITroubleshootingMetadata['errors'][number]
|
||||
) {
|
||||
return formatError(a).localeCompare(formatError(b))
|
||||
}
|
||||
|
||||
return Array.from(allErrors).sort(sortErrors)
|
||||
}
|
||||
|
||||
export function getArticleSlug(entry: ITroubleshootingMetadata) {
|
||||
const slugifiedTitle = entry.title.toLowerCase().replace(/\s+/g, '-')
|
||||
const escapedTitle = encodeURIComponent(slugifiedTitle)
|
||||
return escapedTitle
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type FC, type PropsWithChildren } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
export const LayoutMainContent: FC<PropsWithChildren<{ className?: string }>> = ({
|
||||
className,
|
||||
children,
|
||||
}) => <div className={['max-w-7xl px-5 mx-auto py-8', className].join(' ')}>{children}</div>
|
||||
}) => <div className={cn('max-w-7xl px-5 mx-auto py-8', className)}>{children}</div>
|
||||
@@ -157,9 +157,10 @@ const levelsData = {
|
||||
},
|
||||
}
|
||||
|
||||
const MobileHeader = memo(function MobileHeader({ menuId }: { menuId: MenuId }) {
|
||||
type MobileHeaderProps = { menuId: MenuId } | { menuName: string }
|
||||
|
||||
const MobileHeader = memo(function MobileHeader(props: MobileHeaderProps) {
|
||||
const mobileMenuOpen = useMenuMobileOpen()
|
||||
const menuLevel = menuId
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -208,9 +209,9 @@ const MobileHeader = memo(function MobileHeader({ menuId }: { menuId: MenuId })
|
||||
>
|
||||
{mobileMenuOpen
|
||||
? 'Close'
|
||||
: menuLevel
|
||||
? levelsData[menuLevel]?.name
|
||||
: levelsData['home'].name}
|
||||
: 'menuId' in props
|
||||
? levelsData[props.menuId]?.name ?? levelsData['home'].name
|
||||
: props.menuName}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -326,7 +327,10 @@ const NavContainer = memo(function NavContainer({ children }: PropsWithChildren)
|
||||
|
||||
interface SkeletonProps extends PropsWithChildren {
|
||||
menuId?: MenuId
|
||||
menuName?: string
|
||||
NavigationMenu?: ReactNode
|
||||
hideFooter?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
function TopNavSkeleton({ children }) {
|
||||
@@ -340,12 +344,19 @@ function TopNavSkeleton({ children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSkeleton({ children, menuId, NavigationMenu }: SkeletonProps) {
|
||||
function SidebarSkeleton({
|
||||
children,
|
||||
menuId,
|
||||
menuName,
|
||||
NavigationMenu,
|
||||
hideFooter = false,
|
||||
className,
|
||||
}: SkeletonProps) {
|
||||
const mobileMenuOpen = useMenuMobileOpen()
|
||||
const hideSideNav = !menuId
|
||||
const hideSideNav = !menuId && !NavigationMenu
|
||||
|
||||
return (
|
||||
<div className="flex flex-row h-full relative">
|
||||
<div className={cn('flex flex-row h-full relative', className)}>
|
||||
{!hideSideNav && (
|
||||
<NavContainer>{NavigationMenu ?? <DefaultNavigationMenu menuId={menuId} />}</NavContainer>
|
||||
)}
|
||||
@@ -366,11 +377,15 @@ function SidebarSkeleton({ children, menuId, NavigationMenu }: SkeletonProps) {
|
||||
'backdrop-blur backdrop-filter bg-background'
|
||||
)}
|
||||
>
|
||||
{!hideSideNav && <MobileHeader menuId={menuId} />}
|
||||
{hideSideNav ? null : menuName ? (
|
||||
<MobileHeader menuName={menuName} />
|
||||
) : (
|
||||
<MobileHeader menuId={menuId} />
|
||||
)}
|
||||
</div>
|
||||
<div className="grow">
|
||||
{children}
|
||||
<Footer />
|
||||
{!hideFooter && <Footer />}
|
||||
</div>
|
||||
<MobileMenuBackdrop />
|
||||
</Container>
|
||||
|
||||
@@ -10,3 +10,4 @@ export const IS_PREVIEW = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
||||
export const IS_PRODUCTION = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
|
||||
export const LOCAL_SUPABASE = process.env.NEXT_PUBLIC_LOCAL_SUPABASE === 'true'
|
||||
export const MISC_URL = process.env.NEXT_PUBLIC_MISC_URL ?? ''
|
||||
export const PROD_URL = `https://supabase.com${BASE_PATH}`
|
||||
@@ -14,7 +14,7 @@ import codeHikeTheme from 'config/code-hike.theme.json' assert { type: 'json' }
|
||||
// MUST be process.cwd() here, not import.meta.url, or files that are added
|
||||
// with outputFileTracingIncludes (not auto-traced) will not be found at
|
||||
// runtime.
|
||||
const DOCS_DIRECTORY = process.cwd()
|
||||
export const DOCS_DIRECTORY = process.cwd()
|
||||
export const EXAMPLES_DIRECTORY = join(DOCS_DIRECTORY, '..', '..', 'examples')
|
||||
export const GUIDES_DIRECTORY = join(DOCS_DIRECTORY, 'content/guides')
|
||||
export const REF_DOCS_DIRECTORY = join(DOCS_DIRECTORY, 'docs/ref')
|
||||
|
||||
Vendored
+1
-2
@@ -1,6 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -76,6 +76,7 @@
|
||||
"next-plugin-yaml": "^1.0.1",
|
||||
"next-seo": "^6.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"nuqs": "^1.19.1",
|
||||
"openai": "^4.20.1",
|
||||
"openapi-fetch": "^0.8.2",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -342,3 +342,10 @@ th code {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
@apply sr-only;
|
||||
}
|
||||
.skip-link:focus {
|
||||
@apply not-sr-only;
|
||||
}
|
||||
Generated
+5
-3
@@ -907,6 +907,7 @@
|
||||
"next-plugin-yaml": "^1.0.1",
|
||||
"next-seo": "^6.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"nuqs": "^1.19.1",
|
||||
"openai": "^4.20.1",
|
||||
"openapi-fetch": "^0.8.2",
|
||||
"react": "^18.2.0",
|
||||
@@ -32723,9 +32724,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nuqs": {
|
||||
"version": "1.17.8",
|
||||
"resolved": "https://registry.npmjs.org/nuqs/-/nuqs-1.17.8.tgz",
|
||||
"integrity": "sha512-JqsnzO+hJyjJE7ebuhpHMLA2iGY48e2xr0oJQFhj7kjUmDABL2XOup47rxF5TL/5b9jEsmU2t0lAKin1VdK1/A==",
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/nuqs/-/nuqs-1.19.1.tgz",
|
||||
"integrity": "sha512-oixldNThB1wbu6B5K961++7wpTz/EZFPWnraGmIQhibDT+YxRJNplWMIoPJgL4dlsiSDVI5bbUWKpzsIWVh3Pg==",
|
||||
"dependencies": {
|
||||
"mitt": "^3.0.1"
|
||||
},
|
||||
@@ -43333,6 +43334,7 @@
|
||||
"@babel/preset-env": "^7.23.6",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/common-tags": "^1.8.4",
|
||||
"api-types": "*",
|
||||
"babel-jest": "^29.7.0",
|
||||
"babel-plugin-transform-import-meta": "^2.2.1",
|
||||
"chalk": "^5.3.0",
|
||||
|
||||
Reference in new issue
Block a user