mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 20:35:07 +03:00
Merge branch 'master' into self-hosted/fix-connect
This commit is contained in:
60 files changed
+4334
-373
No files matched your search
@@ -0,0 +1,233 @@
|
||||
---
|
||||
name: e2e-studio-tests
|
||||
description: Run e2e tests in the Studio app. Use when asked to run e2e tests, run studio tests, playwright tests, or test the feature.
|
||||
---
|
||||
|
||||
# E2E Studio Tests
|
||||
|
||||
Run Playwright end-to-end tests for the Studio application.
|
||||
|
||||
## Running Tests
|
||||
|
||||
Tests must be run from the `e2e/studio` directory:
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm run e2e
|
||||
```
|
||||
|
||||
### Run specific file
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm run e2e -- features/cron-jobs.spec.ts
|
||||
```
|
||||
|
||||
### Run with grep filter
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm run e2e -- --grep "test name pattern"
|
||||
```
|
||||
|
||||
### UI mode for debugging
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm run e2e -- --ui
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
- Tests auto-start Supabase local containers via web server config
|
||||
- Self-hosted mode (`IS_PLATFORM=false`) runs tests in parallel (3 workers)
|
||||
- No manual setup needed for self-hosted tests
|
||||
|
||||
## Test File Structure
|
||||
|
||||
- Tests are in `e2e/studio/features/*.spec.ts`
|
||||
- Use custom test utility: `import { test } from '../utils/test.js'`
|
||||
- Test fixtures provide `page`, `ref`, and other helpers
|
||||
|
||||
## Common Patterns
|
||||
|
||||
Wait for elements with generous timeouts:
|
||||
|
||||
```typescript
|
||||
await expect(locator).toBeVisible({ timeout: 30000 })
|
||||
```
|
||||
|
||||
Add messages to expects for debugging:
|
||||
|
||||
```typescript
|
||||
await expect(locator).toBeVisible({ timeout: 30000 }, 'Element should be visible after page load')
|
||||
```
|
||||
|
||||
Use serial mode for tests sharing database state:
|
||||
|
||||
```typescript
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
```
|
||||
|
||||
## Writing Robust Selectors
|
||||
|
||||
### Selector priority (best to worst)
|
||||
|
||||
1. **`getByRole` with accessible name** - Most robust, tests accessibility
|
||||
```typescript
|
||||
page.getByRole('button', { name: 'Save' })
|
||||
page.getByRole('button', { name: 'Configure API privileges' })
|
||||
```
|
||||
|
||||
2. **`getByTestId`** - Stable, explicit test hooks
|
||||
```typescript
|
||||
page.getByTestId('table-editor-side-panel')
|
||||
```
|
||||
|
||||
3. **`getByText` with exact match** - Good for unique text
|
||||
```typescript
|
||||
page.getByText('Data API Access', { exact: true })
|
||||
```
|
||||
|
||||
4. **`locator` with CSS** - Use sparingly, more fragile
|
||||
```typescript
|
||||
page.locator('[data-state="open"]')
|
||||
```
|
||||
|
||||
### Patterns to avoid
|
||||
|
||||
- **XPath selectors** - Fragile to DOM changes
|
||||
```typescript
|
||||
// BAD
|
||||
locator('xpath=ancestor::div[contains(@class, "space-y")]')
|
||||
```
|
||||
|
||||
- **Parent traversal with `locator('..')`** - Breaks when structure changes
|
||||
```typescript
|
||||
// BAD
|
||||
element.locator('..').getByRole('button')
|
||||
```
|
||||
|
||||
- **Broad `filter({ hasText })` on generic elements** - May match multiple elements
|
||||
```typescript
|
||||
// BAD - popover may have more than one combobox
|
||||
// Could consider scoping down the container or filtering the combobox more specifically
|
||||
popover.getByRole('combobox')
|
||||
```
|
||||
|
||||
### Add accessible labels to components
|
||||
|
||||
When a component lacks a good accessible name, add one in the source code:
|
||||
|
||||
```tsx
|
||||
// In the React component
|
||||
<Button aria-label="Configure API privileges">
|
||||
<Settings />
|
||||
</Button>
|
||||
```
|
||||
|
||||
Then use it in tests:
|
||||
```typescript
|
||||
page.getByRole('button', { name: 'Configure API privileges' })
|
||||
```
|
||||
|
||||
### Narrowing search scope
|
||||
|
||||
Scope selectors to specific containers to avoid matching wrong elements:
|
||||
|
||||
```typescript
|
||||
// Good - scoped to side panel
|
||||
const sidePanel = page.getByTestId('table-editor-side-panel')
|
||||
const toggle = sidePanel.getByRole('switch')
|
||||
|
||||
// Good - find unique element, then scope from there
|
||||
const popover = page.locator('[data-radix-popper-content-wrapper]')
|
||||
const roleSection = popover.getByText('Anonymous (anon)', { exact: true })
|
||||
```
|
||||
|
||||
## Avoiding `waitForTimeout`
|
||||
|
||||
Never use `waitForTimeout` - always wait for something specific:
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// GOOD - wait for UI element
|
||||
await expect(page.getByText('Success')).toBeVisible()
|
||||
|
||||
// GOOD - wait for API response
|
||||
const apiPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=table-create')
|
||||
await saveButton.click()
|
||||
await apiPromise
|
||||
|
||||
// GOOD - wait for toast indicating operation complete
|
||||
await expect(page.getByText('Table created successfully')).toBeVisible({ timeout: 15000 })
|
||||
```
|
||||
|
||||
## Avoiding `force: true` on clicks
|
||||
|
||||
Instead of forcing clicks on hidden elements, make them visible first:
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
await menuButton.click({ force: true })
|
||||
|
||||
// GOOD - hover to reveal, then click
|
||||
await tableRow.hover()
|
||||
await expect(menuButton).toBeVisible()
|
||||
await menuButton.click()
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### View trace
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm exec playwright show-trace <path-to-trace.zip>
|
||||
```
|
||||
|
||||
### View HTML report
|
||||
|
||||
```bash
|
||||
cd e2e/studio && pnpm exec playwright show-report
|
||||
```
|
||||
|
||||
### Error context
|
||||
|
||||
Error context files are saved in the `test-results/` directory.
|
||||
|
||||
### Playwright MCP tools
|
||||
|
||||
Use Playwright MCP tools to inspect UI when debugging locally.
|
||||
|
||||
## CI vs Local Development
|
||||
|
||||
The key difference is **cold start vs warm state**:
|
||||
|
||||
### CI (cold start)
|
||||
|
||||
Tests run from a blank database slate. Each test run resets the database and starts fresh containers. Extensions like pg_cron are NOT enabled by default.
|
||||
|
||||
### Local dev with `pnpm dev:studio-local`
|
||||
|
||||
When debugging with a running dev server, the database may already have state from previous runs (extensions enabled, test data present).
|
||||
|
||||
## Handling Cold Start Bugs
|
||||
|
||||
Tests that work locally but fail in CI often have assumptions about existing state.
|
||||
|
||||
### Common issues
|
||||
|
||||
1. Extension not enabled (must enable in test setup)
|
||||
2. Race conditions when parallel tests try to modify shared state (use `test.describe.configure({ mode: 'serial' })`)
|
||||
3. Locators matching wrong elements because the page structure differs when state isn't set up
|
||||
|
||||
### Reproducing CI behavior locally
|
||||
|
||||
The test framework automatically resets the database when running `pnpm run e2e`. This matches CI behavior.
|
||||
|
||||
If using `pnpm dev:studio-local` for Playwright MCP debugging, remember the state differs from CI.
|
||||
|
||||
## Debugging Workflow for CI Failures
|
||||
|
||||
1. First, run the test locally with `pnpm run e2e -- features/<file>.spec.ts` (cold start)
|
||||
2. Check error context in `test-results/` directory
|
||||
3. If you need to inspect UI state, start `pnpm dev:studio-local` and use Playwright MCP tools
|
||||
4. Remember: what you see in the dev server may have state that doesn't exist in CI
|
||||
@@ -120,6 +120,8 @@ next-env.d.ts
|
||||
.claude/*
|
||||
!.claude/settings.json
|
||||
!.claude/scripts/
|
||||
!.claude/skills/
|
||||
.claude/skills/me-*
|
||||
CLAUDE.md
|
||||
|
||||
#include template .env file for docker-compose
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useParams } from 'common'
|
||||
import BarChart from 'components/ui/Charts/BarChart'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { ChartIntervalDropdown } from 'components/ui/Logs/ChartIntervalDropdown'
|
||||
import { CHART_INTERVALS } from 'components/ui/Logs/logs.utils'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import {
|
||||
ProjectLogStatsVariables,
|
||||
@@ -14,55 +15,13 @@ import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { Auth, Database, Realtime, Storage } from 'icons'
|
||||
import sumBy from 'lodash/sumBy'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import type { ChartIntervals } from 'types'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
Loading,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
import { Loading } from 'ui'
|
||||
|
||||
type ChartIntervalKey = ProjectLogStatsVariables['interval']
|
||||
|
||||
const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90, platform: 1 }
|
||||
|
||||
const CHART_INTERVALS: ChartIntervals[] = [
|
||||
{
|
||||
key: '1hr',
|
||||
label: 'Last 60 minutes',
|
||||
startValue: 1,
|
||||
startUnit: 'hour',
|
||||
format: 'MMM D, h:mma',
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
|
||||
},
|
||||
{
|
||||
key: '1day',
|
||||
label: 'Last 24 hours',
|
||||
startValue: 24,
|
||||
startUnit: 'hour',
|
||||
format: 'MMM D, ha',
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
|
||||
},
|
||||
{
|
||||
key: '7day',
|
||||
label: 'Last 7 days',
|
||||
startValue: 7,
|
||||
startUnit: 'day',
|
||||
format: 'MMM D',
|
||||
availableIn: ['pro', 'team', 'enterprise'],
|
||||
},
|
||||
]
|
||||
|
||||
const ProjectUsage = () => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef } = useParams()
|
||||
@@ -132,64 +91,15 @@ const ProjectUsage = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown size={14} />}>
|
||||
<span>{selectedInterval.label}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start" className="w-40">
|
||||
<DropdownMenuRadioGroup
|
||||
value={interval}
|
||||
onValueChange={(interval) =>
|
||||
setInterval(interval as ProjectLogStatsVariables['interval'])
|
||||
}
|
||||
>
|
||||
{CHART_INTERVALS.map((i) => {
|
||||
const disabled = !i.availableIn?.includes(plan?.id || 'free')
|
||||
|
||||
if (disabled) {
|
||||
const retentionDuration = LOG_RETENTION[plan?.id ?? 'free']
|
||||
return (
|
||||
<Tooltip key={i.key}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuRadioItem
|
||||
disabled
|
||||
value={i.key}
|
||||
className="!pointer-events-auto"
|
||||
>
|
||||
{i.label}
|
||||
</DropdownMenuRadioItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>
|
||||
{plan?.name} plan only includes up to {retentionDuration} day
|
||||
{retentionDuration > 1 ? 's' : ''} of log retention
|
||||
</p>
|
||||
<p className="text-foreground-light">
|
||||
<InlineLink
|
||||
className="text-foreground-light hover:text-foreground"
|
||||
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}
|
||||
>
|
||||
Upgrade your plan
|
||||
</InlineLink>{' '}
|
||||
to increase log retention and view statistics for the{' '}
|
||||
{i.label.toLowerCase()}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<DropdownMenuRadioItem key={i.key} value={i.key}>
|
||||
{i.label}
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ChartIntervalDropdown
|
||||
value={interval || '1day'}
|
||||
onChange={(interval) => setInterval(interval as ProjectLogStatsVariables['interval'])}
|
||||
planId={plan?.id}
|
||||
planName={plan?.name}
|
||||
organizationSlug={organization?.slug}
|
||||
dropdownAlign="start"
|
||||
tooltipSide="right"
|
||||
/>
|
||||
<span className="text-xs text-foreground-light">
|
||||
Statistics for {selectedInterval.label.toLowerCase()}
|
||||
</span>
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
import { useParams } from 'common'
|
||||
import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder'
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { ChartIntervalDropdown } from 'components/ui/Logs/ChartIntervalDropdown'
|
||||
import { CHART_INTERVALS } from 'components/ui/Logs/logs.utils'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ChartIntervals } from 'types'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
Loading,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { Card, CardContent, CardHeader, CardTitle, Loading } from 'ui'
|
||||
import { Row } from 'ui-patterns'
|
||||
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
|
||||
|
||||
@@ -41,35 +24,6 @@ import {
|
||||
sumWarnings,
|
||||
} from './ProjectUsage.metrics'
|
||||
|
||||
const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90, platform: 1 }
|
||||
|
||||
const CHART_INTERVALS: ChartIntervals[] = [
|
||||
{
|
||||
key: '1hr',
|
||||
label: 'Last 60 minutes',
|
||||
startValue: 1,
|
||||
startUnit: 'hour',
|
||||
format: 'MMM D, h:mma',
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
|
||||
},
|
||||
{
|
||||
key: '1day',
|
||||
label: 'Last 24 hours',
|
||||
startValue: 24,
|
||||
startUnit: 'hour',
|
||||
format: 'MMM D, ha',
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
|
||||
},
|
||||
{
|
||||
key: '7day',
|
||||
label: 'Last 7 days',
|
||||
startValue: 7,
|
||||
startUnit: 'day',
|
||||
format: 'MMM D, ha',
|
||||
availableIn: ['pro', 'team', 'enterprise'],
|
||||
},
|
||||
]
|
||||
|
||||
type ChartIntervalKey = '1hr' | '1day' | '7day'
|
||||
|
||||
type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime'
|
||||
@@ -242,62 +196,15 @@ export const ProjectUsageSection = () => {
|
||||
<span>Success Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown size={14} />}>
|
||||
<span>{selectedInterval.label}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-40">
|
||||
<DropdownMenuRadioGroup
|
||||
value={interval}
|
||||
onValueChange={(interval) => setInterval(interval as ChartIntervalKey)}
|
||||
>
|
||||
{CHART_INTERVALS.map((i) => {
|
||||
const disabled = !i.availableIn?.includes(plan?.id || 'free')
|
||||
|
||||
if (disabled) {
|
||||
const retentionDuration = LOG_RETENTION[plan?.id ?? 'free']
|
||||
return (
|
||||
<Tooltip key={i.key}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuRadioItem
|
||||
disabled
|
||||
value={i.key}
|
||||
className="!pointer-events-auto"
|
||||
>
|
||||
{i.label}
|
||||
</DropdownMenuRadioItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>
|
||||
{plan?.name} plan only includes up to {retentionDuration} day
|
||||
{retentionDuration > 1 ? 's' : ''} of log retention
|
||||
</p>
|
||||
<p className="text-foreground-light">
|
||||
<InlineLink
|
||||
className="text-foreground-light hover:text-foreground"
|
||||
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}
|
||||
>
|
||||
Upgrade your plan
|
||||
</InlineLink>{' '}
|
||||
to increase log retention and view statistics for the{' '}
|
||||
{i.label.toLowerCase()}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<DropdownMenuRadioItem key={i.key} value={i.key}>
|
||||
{i.label}
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ChartIntervalDropdown
|
||||
value={interval}
|
||||
onChange={(interval) => setInterval(interval as ChartIntervalKey)}
|
||||
planId={plan?.id}
|
||||
planName={plan?.name}
|
||||
organizationSlug={organization?.slug}
|
||||
dropdownAlign="end"
|
||||
tooltipSide="left"
|
||||
/>
|
||||
</div>
|
||||
<Row columns={[3, 2, 1]}>
|
||||
{enabledServices.map((s) => (
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useParams } from 'common'
|
||||
import { useInfraMonitoringAttributesQuery } from 'data/analytics/infra-monitoring-query'
|
||||
import { useMaxConnectionsQuery } from 'data/database/max-connections-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
|
||||
import Link from 'next/link'
|
||||
import { useMemo } from 'react'
|
||||
import { cn } from 'ui'
|
||||
import {
|
||||
MetricCard,
|
||||
MetricCardContent,
|
||||
MetricCardHeader,
|
||||
MetricCardLabel,
|
||||
MetricCardValue,
|
||||
} from 'ui-patterns/MetricCard'
|
||||
|
||||
import {
|
||||
parseConnectionsData,
|
||||
parseInfrastructureMetrics,
|
||||
} from './DatabaseInfrastructureSection.utils'
|
||||
|
||||
type DatabaseInfrastructureSectionProps = {
|
||||
interval: '1hr' | '1day' | '7day'
|
||||
refreshKey: number
|
||||
dbErrorRate: number
|
||||
isLoading: boolean
|
||||
slowQueriesCount?: number
|
||||
slowQueriesLoading?: boolean
|
||||
}
|
||||
|
||||
export const DatabaseInfrastructureSection = ({
|
||||
interval,
|
||||
refreshKey,
|
||||
dbErrorRate,
|
||||
isLoading: dbLoading,
|
||||
slowQueriesCount = 0,
|
||||
slowQueriesLoading = false,
|
||||
}: DatabaseInfrastructureSectionProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
// refreshKey forces date recalculation when user clicks refresh button
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const { startDate, endDate, infraInterval } = useMemo(() => {
|
||||
const now = dayjs()
|
||||
const end = now.toISOString()
|
||||
let start: string
|
||||
let infraInterval: '1h' | '1d'
|
||||
|
||||
switch (interval) {
|
||||
case '1hr':
|
||||
start = now.subtract(1, 'hour').toISOString()
|
||||
infraInterval = '1h'
|
||||
break
|
||||
case '1day':
|
||||
start = now.subtract(1, 'day').toISOString()
|
||||
infraInterval = '1h'
|
||||
break
|
||||
case '7day':
|
||||
start = now.subtract(7, 'day').toISOString()
|
||||
infraInterval = '1d'
|
||||
break
|
||||
default:
|
||||
start = now.subtract(1, 'hour').toISOString()
|
||||
infraInterval = '1h'
|
||||
}
|
||||
|
||||
return { startDate: start, endDate: end, infraInterval }
|
||||
}, [interval, refreshKey])
|
||||
|
||||
const {
|
||||
data: infraData,
|
||||
isLoading: infraLoading,
|
||||
error: infraError,
|
||||
} = useInfraMonitoringAttributesQuery({
|
||||
projectRef,
|
||||
attributes: [
|
||||
'avg_cpu_usage',
|
||||
'ram_usage',
|
||||
'disk_io_consumption',
|
||||
'pg_stat_database_num_backends',
|
||||
],
|
||||
startDate,
|
||||
endDate,
|
||||
interval: infraInterval,
|
||||
})
|
||||
|
||||
const { data: maxConnectionsData } = useMaxConnectionsQuery({
|
||||
projectRef,
|
||||
connectionString: project?.connectionString,
|
||||
})
|
||||
|
||||
const metrics = useMemo(() => parseInfrastructureMetrics(infraData), [infraData])
|
||||
|
||||
const connections = useMemo(
|
||||
() => parseConnectionsData(infraData, maxConnectionsData),
|
||||
[infraData, maxConnectionsData]
|
||||
)
|
||||
|
||||
const errorMessage =
|
||||
infraError && typeof infraError === 'object' && 'message' in infraError
|
||||
? String(infraError.message)
|
||||
: 'Error loading data'
|
||||
|
||||
// Generate database report URL with time range parameters
|
||||
const getDatabaseReportUrl = () => {
|
||||
const now = dayjs()
|
||||
let its: string
|
||||
let helperText: string
|
||||
|
||||
switch (interval) {
|
||||
case '1hr':
|
||||
its = now.subtract(1, 'hour').toISOString()
|
||||
helperText = 'Last 60 minutes'
|
||||
break
|
||||
case '1day':
|
||||
its = now.subtract(24, 'hour').toISOString()
|
||||
helperText = 'Last 24 hours'
|
||||
break
|
||||
case '7day':
|
||||
its = now.subtract(7, 'day').toISOString()
|
||||
helperText = 'Last 7 days'
|
||||
break
|
||||
default:
|
||||
its = now.subtract(24, 'hour').toISOString()
|
||||
helperText = 'Last 24 hours'
|
||||
}
|
||||
|
||||
const ite = now.toISOString()
|
||||
const params = new URLSearchParams({
|
||||
its,
|
||||
ite,
|
||||
isHelper: 'true',
|
||||
helperText,
|
||||
})
|
||||
|
||||
return `/project/${projectRef}/observability/database?${params.toString()}`
|
||||
}
|
||||
|
||||
const databaseReportUrl = getDatabaseReportUrl()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-4">Database</h2>
|
||||
{/* First row: Metrics */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Link href={databaseReportUrl} className="block group">
|
||||
<MetricCard isLoading={dbLoading}>
|
||||
<MetricCardHeader href={databaseReportUrl} linkTooltip="Go to database report">
|
||||
<MetricCardLabel tooltip="Percentage of database operations resulting in errors or warnings">
|
||||
Error Rate
|
||||
</MetricCardLabel>
|
||||
</MetricCardHeader>
|
||||
<MetricCardContent>
|
||||
<MetricCardValue>{dbErrorRate.toFixed(2)}%</MetricCardValue>
|
||||
</MetricCardContent>
|
||||
</MetricCard>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/project/${projectRef}/observability/query-performance?totalTimeFilter=${encodeURIComponent(JSON.stringify({ operator: '>', value: 1000 }))}`}
|
||||
className="block group"
|
||||
>
|
||||
<MetricCard isLoading={slowQueriesLoading}>
|
||||
<MetricCardHeader
|
||||
href={`/project/${projectRef}/observability/query-performance?totalTimeFilter=${encodeURIComponent(JSON.stringify({ operator: '>', value: 1000 }))}`}
|
||||
linkTooltip="Go to query performance"
|
||||
>
|
||||
<MetricCardLabel tooltip="Queries with total execution time (execution time + planning time) greater than 1000ms. High values may indicate query optimization opportunities">
|
||||
Slow Queries
|
||||
</MetricCardLabel>
|
||||
</MetricCardHeader>
|
||||
<MetricCardContent>
|
||||
<MetricCardValue>{slowQueriesCount}</MetricCardValue>
|
||||
</MetricCardContent>
|
||||
</MetricCard>
|
||||
</Link>
|
||||
|
||||
<Link href={databaseReportUrl} className="block group">
|
||||
<MetricCard isLoading={infraLoading}>
|
||||
<MetricCardHeader href={databaseReportUrl} linkTooltip="Go to database report">
|
||||
<MetricCardLabel tooltip="Active database connections (current/max). Monitor to avoid connection exhaustion">
|
||||
Connections
|
||||
</MetricCardLabel>
|
||||
</MetricCardHeader>
|
||||
<MetricCardContent>
|
||||
{infraError ? (
|
||||
<div className="text-xs text-destructive break-words">{errorMessage}</div>
|
||||
) : connections.max > 0 ? (
|
||||
<MetricCardValue>
|
||||
{connections.current}/{connections.max}
|
||||
</MetricCardValue>
|
||||
) : (
|
||||
<MetricCardValue>--</MetricCardValue>
|
||||
)}
|
||||
</MetricCardContent>
|
||||
</MetricCard>
|
||||
</Link>
|
||||
|
||||
<Link href={databaseReportUrl} className="block group">
|
||||
<MetricCard isLoading={infraLoading}>
|
||||
<MetricCardHeader href={databaseReportUrl} linkTooltip="Go to database report">
|
||||
<MetricCardLabel tooltip="Disk I/O consumption percentage. High values may indicate disk bottlenecks">
|
||||
Disk
|
||||
</MetricCardLabel>
|
||||
</MetricCardHeader>
|
||||
<MetricCardContent>
|
||||
{infraError ? (
|
||||
<div className="text-xs text-destructive break-words">{errorMessage}</div>
|
||||
) : metrics ? (
|
||||
<MetricCardValue>{metrics.disk.current.toFixed(0)}%</MetricCardValue>
|
||||
) : (
|
||||
<MetricCardValue>--</MetricCardValue>
|
||||
)}
|
||||
</MetricCardContent>
|
||||
</MetricCard>
|
||||
</Link>
|
||||
|
||||
<Link href={databaseReportUrl} className="block group">
|
||||
<MetricCard isLoading={infraLoading}>
|
||||
<MetricCardHeader href={databaseReportUrl} linkTooltip="Go to database report">
|
||||
<MetricCardLabel tooltip="RAM usage percentage. Sustained high usage may indicate memory pressure">
|
||||
Memory
|
||||
</MetricCardLabel>
|
||||
</MetricCardHeader>
|
||||
<MetricCardContent>
|
||||
{infraError ? (
|
||||
<div className="text-xs text-destructive break-words">{errorMessage}</div>
|
||||
) : metrics ? (
|
||||
<MetricCardValue>{metrics.ram.current.toFixed(0)}%</MetricCardValue>
|
||||
) : (
|
||||
<MetricCardValue>--</MetricCardValue>
|
||||
)}
|
||||
</MetricCardContent>
|
||||
</MetricCard>
|
||||
</Link>
|
||||
|
||||
<Link href={databaseReportUrl} className="block group">
|
||||
<MetricCard isLoading={infraLoading}>
|
||||
<MetricCardHeader href={databaseReportUrl} linkTooltip="Go to database report">
|
||||
<MetricCardLabel tooltip="CPU usage percentage. High values may suggest CPU-intensive queries or workloads">
|
||||
CPU
|
||||
</MetricCardLabel>
|
||||
</MetricCardHeader>
|
||||
<MetricCardContent>
|
||||
{infraError ? (
|
||||
<div className="text-xs text-destructive break-words">{errorMessage}</div>
|
||||
) : metrics ? (
|
||||
<MetricCardValue>{metrics.cpu.current.toFixed(0)}%</MetricCardValue>
|
||||
) : (
|
||||
<MetricCardValue>--</MetricCardValue>
|
||||
)}
|
||||
</MetricCardContent>
|
||||
</MetricCard>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
import type {
|
||||
InfraMonitoringMultiResponse,
|
||||
InfraMonitoringSingleResponse,
|
||||
} from 'data/analytics/infra-monitoring-query'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
parseConnectionsData,
|
||||
parseInfrastructureMetrics,
|
||||
parseNumericValue,
|
||||
} from './DatabaseInfrastructureSection.utils'
|
||||
|
||||
describe('parseNumericValue', () => {
|
||||
it('returns number value as-is', () => {
|
||||
expect(parseNumericValue(42)).toBe(42)
|
||||
expect(parseNumericValue(0)).toBe(0)
|
||||
expect(parseNumericValue(3.14)).toBe(3.14)
|
||||
})
|
||||
|
||||
it('parses valid string numbers', () => {
|
||||
expect(parseNumericValue('42')).toBe(42)
|
||||
expect(parseNumericValue('3.14')).toBe(3.14)
|
||||
expect(parseNumericValue('0')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 for invalid string values', () => {
|
||||
expect(parseNumericValue('invalid')).toBe(0)
|
||||
expect(parseNumericValue('')).toBe(0)
|
||||
expect(parseNumericValue('NaN')).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 for undefined', () => {
|
||||
expect(parseNumericValue(undefined)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseInfrastructureMetrics', () => {
|
||||
it('returns null for undefined data', () => {
|
||||
expect(parseInfrastructureMetrics(undefined)).toBe(null)
|
||||
})
|
||||
|
||||
it('parses valid metrics from response', () => {
|
||||
const mockResponse: InfraMonitoringMultiResponse = {
|
||||
data: [],
|
||||
series: {
|
||||
avg_cpu_usage: { format: 'percent', total: 150, totalAverage: 50, yAxisLimit: 100 },
|
||||
ram_usage: { format: 'bytes', total: 180, totalAverage: 60, yAxisLimit: 100 },
|
||||
disk_io_consumption: {
|
||||
format: 'percent',
|
||||
total: 210,
|
||||
totalAverage: 70,
|
||||
yAxisLimit: 100,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = parseInfrastructureMetrics(mockResponse)
|
||||
|
||||
expect(result).toEqual({
|
||||
cpu: { current: 50, max: 100 },
|
||||
ram: { current: 60, max: 100 },
|
||||
disk: { current: 70, max: 100 },
|
||||
})
|
||||
})
|
||||
|
||||
it('handles string values in metrics', () => {
|
||||
const mockResponse: InfraMonitoringMultiResponse = {
|
||||
data: [],
|
||||
series: {
|
||||
avg_cpu_usage: { format: 'percent', total: 150, totalAverage: '50.5', yAxisLimit: 100 },
|
||||
ram_usage: { format: 'bytes', total: 180, totalAverage: '60.8', yAxisLimit: 100 },
|
||||
disk_io_consumption: {
|
||||
format: 'percent',
|
||||
total: 210,
|
||||
totalAverage: '70.2',
|
||||
yAxisLimit: 100,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = parseInfrastructureMetrics(mockResponse)
|
||||
|
||||
expect(result).toEqual({
|
||||
cpu: { current: 50.5, max: 100 },
|
||||
ram: { current: 60.8, max: 100 },
|
||||
disk: { current: 70.2, max: 100 },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 0 for missing metrics', () => {
|
||||
const mockResponse: InfraMonitoringMultiResponse = {
|
||||
data: [],
|
||||
series: {},
|
||||
}
|
||||
|
||||
const result = parseInfrastructureMetrics(mockResponse)
|
||||
|
||||
expect(result).toEqual({
|
||||
cpu: { current: 0, max: 100 },
|
||||
ram: { current: 0, max: 100 },
|
||||
disk: { current: 0, max: 100 },
|
||||
})
|
||||
})
|
||||
|
||||
it('handles partial metrics data', () => {
|
||||
const mockResponse: InfraMonitoringMultiResponse = {
|
||||
data: [],
|
||||
series: {
|
||||
avg_cpu_usage: { format: 'percent', total: 150, totalAverage: 50, yAxisLimit: 100 },
|
||||
},
|
||||
}
|
||||
|
||||
const result = parseInfrastructureMetrics(mockResponse)
|
||||
|
||||
expect(result).toEqual({
|
||||
cpu: { current: 50, max: 100 },
|
||||
ram: { current: 0, max: 100 },
|
||||
disk: { current: 0, max: 100 },
|
||||
})
|
||||
})
|
||||
|
||||
it('handles single-response format (legacy)', () => {
|
||||
const mockResponse: InfraMonitoringSingleResponse = {
|
||||
data: [],
|
||||
format: 'percent',
|
||||
total: 150,
|
||||
totalAverage: 50,
|
||||
yAxisLimit: 100,
|
||||
}
|
||||
|
||||
const result = parseInfrastructureMetrics(mockResponse)
|
||||
|
||||
expect(result).toEqual({
|
||||
cpu: { current: 0, max: 100 },
|
||||
ram: { current: 0, max: 100 },
|
||||
disk: { current: 0, max: 100 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseConnectionsData', () => {
|
||||
it('returns zeros when data is undefined', () => {
|
||||
expect(parseConnectionsData(undefined, undefined)).toEqual({ current: 0, max: 0 })
|
||||
expect(parseConnectionsData(undefined, { maxConnections: 100 })).toEqual({
|
||||
current: 0,
|
||||
max: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('parses connections data correctly', () => {
|
||||
const mockInfraData: InfraMonitoringMultiResponse = {
|
||||
data: [],
|
||||
series: {
|
||||
pg_stat_database_num_backends: {
|
||||
format: 'number',
|
||||
total: 150,
|
||||
totalAverage: 25.5,
|
||||
yAxisLimit: 100,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const mockMaxData = { maxConnections: 100 }
|
||||
|
||||
const result = parseConnectionsData(mockInfraData, mockMaxData)
|
||||
|
||||
expect(result).toEqual({ current: 26, max: 100 }) // 25.5 rounded to 26
|
||||
})
|
||||
|
||||
it('handles string values for connections', () => {
|
||||
const mockInfraData: InfraMonitoringMultiResponse = {
|
||||
data: [],
|
||||
series: {
|
||||
pg_stat_database_num_backends: {
|
||||
format: 'number',
|
||||
total: 150,
|
||||
totalAverage: '30.7',
|
||||
yAxisLimit: 100,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const mockMaxData = { maxConnections: 100 }
|
||||
|
||||
const result = parseConnectionsData(mockInfraData, mockMaxData)
|
||||
|
||||
expect(result).toEqual({ current: 31, max: 100 }) // 30.7 rounded to 31
|
||||
})
|
||||
|
||||
it('returns current connections even when maxConnectionsData is undefined', () => {
|
||||
const mockInfraData: InfraMonitoringMultiResponse = {
|
||||
data: [],
|
||||
series: {
|
||||
pg_stat_database_num_backends: {
|
||||
format: 'number',
|
||||
total: 150,
|
||||
totalAverage: 25,
|
||||
yAxisLimit: 100,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = parseConnectionsData(mockInfraData, undefined)
|
||||
|
||||
expect(result).toEqual({ current: 25, max: 0 })
|
||||
})
|
||||
|
||||
it('returns 0 max when maxConnections is missing from data object', () => {
|
||||
const mockInfraData: InfraMonitoringMultiResponse = {
|
||||
data: [],
|
||||
series: {
|
||||
pg_stat_database_num_backends: {
|
||||
format: 'number',
|
||||
total: 150,
|
||||
totalAverage: 25,
|
||||
yAxisLimit: 100,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const mockMaxData = {}
|
||||
|
||||
const result = parseConnectionsData(mockInfraData, mockMaxData)
|
||||
|
||||
expect(result).toEqual({ current: 25, max: 0 })
|
||||
})
|
||||
|
||||
it('returns 0 current when connections metric is missing', () => {
|
||||
const mockInfraData: InfraMonitoringMultiResponse = {
|
||||
data: [],
|
||||
series: {},
|
||||
}
|
||||
|
||||
const mockMaxData = { maxConnections: 100 }
|
||||
|
||||
const result = parseConnectionsData(mockInfraData, mockMaxData)
|
||||
|
||||
expect(result).toEqual({ current: 0, max: 100 })
|
||||
})
|
||||
|
||||
it('handles single-response format (legacy)', () => {
|
||||
const mockInfraData: InfraMonitoringSingleResponse = {
|
||||
data: [],
|
||||
format: 'number',
|
||||
total: 150,
|
||||
totalAverage: 25,
|
||||
yAxisLimit: 100,
|
||||
}
|
||||
|
||||
const mockMaxData = { maxConnections: 100 }
|
||||
|
||||
const result = parseConnectionsData(mockInfraData, mockMaxData)
|
||||
|
||||
expect(result).toEqual({ current: 0, max: 100 })
|
||||
})
|
||||
})
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
InfraMonitoringMultiResponse,
|
||||
InfraMonitoringResponse,
|
||||
} from 'data/analytics/infra-monitoring-query'
|
||||
|
||||
type NumericValue = string | number | undefined
|
||||
|
||||
/**
|
||||
* Parses a numeric value that can be a number, string, or undefined.
|
||||
* Returns 0 for invalid or missing values.
|
||||
*/
|
||||
export function parseNumericValue(val: NumericValue): number {
|
||||
if (typeof val === 'number') return val
|
||||
if (typeof val === 'string') return parseFloat(val) || 0
|
||||
return 0
|
||||
}
|
||||
|
||||
type MetricData = {
|
||||
current: number
|
||||
max: number
|
||||
}
|
||||
|
||||
type InfrastructureMetrics = {
|
||||
cpu: MetricData
|
||||
ram: MetricData
|
||||
disk: MetricData
|
||||
}
|
||||
|
||||
export function parseInfrastructureMetrics(
|
||||
infraData: InfraMonitoringResponse | undefined
|
||||
): InfrastructureMetrics | null {
|
||||
if (!infraData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const series = 'series' in infraData ? infraData.series : {}
|
||||
|
||||
const cpuValue = parseNumericValue(series.avg_cpu_usage?.totalAverage)
|
||||
const ramValue = parseNumericValue(series.ram_usage?.totalAverage)
|
||||
const diskValue = parseNumericValue(series.disk_io_consumption?.totalAverage)
|
||||
|
||||
return {
|
||||
cpu: {
|
||||
current: cpuValue,
|
||||
max: 100,
|
||||
},
|
||||
ram: {
|
||||
current: ramValue,
|
||||
max: 100,
|
||||
},
|
||||
disk: {
|
||||
current: diskValue,
|
||||
max: 100,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectionsData = {
|
||||
current: number
|
||||
max: number
|
||||
}
|
||||
|
||||
type MaxConnectionsData = {
|
||||
maxConnections?: number
|
||||
}
|
||||
|
||||
export function parseConnectionsData(
|
||||
infraData: InfraMonitoringResponse | undefined,
|
||||
maxConnectionsData: MaxConnectionsData | undefined
|
||||
): ConnectionsData {
|
||||
if (!infraData) {
|
||||
return { current: 0, max: 0 }
|
||||
}
|
||||
|
||||
const series = 'series' in infraData ? infraData.series : {}
|
||||
|
||||
const currentVal = series.pg_stat_database_num_backends?.totalAverage
|
||||
const current = Math.round(parseNumericValue(currentVal))
|
||||
const max = maxConnectionsData?.maxConnections || 0
|
||||
|
||||
return { current, max }
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useFlag, useParams } from 'common'
|
||||
import ReportHeader from 'components/interfaces/Reports/ReportHeader'
|
||||
import ReportPadding from 'components/interfaces/Reports/ReportPadding'
|
||||
import { ChartIntervalDropdown } from 'components/ui/Logs/ChartIntervalDropdown'
|
||||
import { CHART_INTERVALS } from 'components/ui/Logs/logs.utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Badge, Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { DatabaseInfrastructureSection } from './DatabaseInfrastructureSection'
|
||||
import { ObservabilityOverviewFooter } from './ObservabilityOverviewFooter'
|
||||
import { useObservabilityOverviewData } from './ObservabilityOverview.utils'
|
||||
import { ServiceHealthTable } from './ServiceHealthTable'
|
||||
import { useSlowQueriesCount } from './useSlowQueriesCount'
|
||||
|
||||
type ChartIntervalKey = '1hr' | '1day' | '7day'
|
||||
|
||||
export const ObservabilityOverview = () => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
const { plan } = useCurrentOrgPlan()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const authReportEnabled = useFlag('authreportv2')
|
||||
const edgeFnReportEnabled = useFlag('edgefunctionreport')
|
||||
const realtimeReportEnabled = useFlag('realtimeReport')
|
||||
const storageReportEnabled = useFlag('storagereport')
|
||||
const postgrestReportEnabled = useFlag('postgrestreport')
|
||||
const { projectStorageAll: storageSupported } = useIsFeatureEnabled(['project_storage:all'])
|
||||
|
||||
const DEFAULT_INTERVAL: ChartIntervalKey = '1day'
|
||||
const [interval, setInterval] = useState<ChartIntervalKey>(DEFAULT_INTERVAL)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
|
||||
const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1]
|
||||
|
||||
const { datetimeFormat } = useMemo(() => {
|
||||
const format = selectedInterval.format || 'MMM D, ha'
|
||||
return { datetimeFormat: format }
|
||||
}, [selectedInterval])
|
||||
|
||||
const overviewData = useObservabilityOverviewData(projectRef!, interval, refreshKey)
|
||||
|
||||
const { slowQueriesCount, isLoading: slowQueriesLoading } = useSlowQueriesCount(
|
||||
projectRef,
|
||||
refreshKey
|
||||
)
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshKey((prev) => prev + 1)
|
||||
queryClient.invalidateQueries({ queryKey: ['project-metrics'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['postgrest-overview-metrics'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['infra-monitoring'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['max-connections'] })
|
||||
}, [queryClient])
|
||||
|
||||
const serviceBase = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'db' as const,
|
||||
name: 'Database',
|
||||
reportUrl: `/project/${projectRef}/observability/database`,
|
||||
logsUrl: `/project/${projectRef}/logs/postgres-logs`,
|
||||
enabled: true,
|
||||
hasReport: true,
|
||||
},
|
||||
{
|
||||
key: 'auth' as const,
|
||||
name: 'Auth',
|
||||
reportUrl: `/project/${projectRef}/observability/auth`,
|
||||
logsUrl: `/project/${projectRef}/logs/auth-logs`,
|
||||
enabled: true,
|
||||
hasReport: authReportEnabled,
|
||||
},
|
||||
{
|
||||
key: 'functions' as const,
|
||||
name: 'Edge Functions',
|
||||
reportUrl: `/project/${projectRef}/observability/edge-functions`,
|
||||
logsUrl: `/project/${projectRef}/logs/edge-functions-logs`,
|
||||
enabled: true,
|
||||
hasReport: edgeFnReportEnabled,
|
||||
},
|
||||
{
|
||||
key: 'realtime' as const,
|
||||
name: 'Realtime',
|
||||
reportUrl: `/project/${projectRef}/observability/realtime`,
|
||||
logsUrl: `/project/${projectRef}/logs/realtime-logs`,
|
||||
enabled: true,
|
||||
hasReport: realtimeReportEnabled,
|
||||
},
|
||||
{
|
||||
key: 'storage' as const,
|
||||
name: 'Storage',
|
||||
reportUrl: `/project/${projectRef}/observability/storage`,
|
||||
logsUrl: `/project/${projectRef}/logs/storage-logs`,
|
||||
enabled: storageSupported,
|
||||
hasReport: storageReportEnabled,
|
||||
},
|
||||
{
|
||||
key: 'postgrest' as const,
|
||||
name: 'Data API',
|
||||
reportUrl: `/project/${projectRef}/observability/postgrest`,
|
||||
logsUrl: `/project/${projectRef}/logs/postgrest-logs`,
|
||||
enabled: true,
|
||||
hasReport: postgrestReportEnabled,
|
||||
},
|
||||
],
|
||||
[
|
||||
projectRef,
|
||||
authReportEnabled,
|
||||
edgeFnReportEnabled,
|
||||
realtimeReportEnabled,
|
||||
storageReportEnabled,
|
||||
storageSupported,
|
||||
postgrestReportEnabled,
|
||||
]
|
||||
)
|
||||
|
||||
const enabledServices = serviceBase.filter((s) => s.enabled)
|
||||
|
||||
const dbServiceData = overviewData.services.db
|
||||
|
||||
// Creates a 1-hour time window for the clicked bar for log filtering
|
||||
const handleBarClick = useCallback(
|
||||
(serviceKey: string, logsUrl: string) => (datum: any) => {
|
||||
if (!datum?.timestamp) return
|
||||
|
||||
const datumTimestamp = dayjs(datum.timestamp)
|
||||
// Round down to the start of the hour
|
||||
const start = datumTimestamp.startOf('hour').toISOString()
|
||||
// Add 1 hour to get the end of the hour
|
||||
const end = datumTimestamp.startOf('hour').add(1, 'hour').toISOString()
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
its: start,
|
||||
ite: end,
|
||||
})
|
||||
|
||||
router.push(`${logsUrl}?${queryParams.toString()}`)
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
return (
|
||||
<ReportPadding>
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<ReportHeader title="Overview" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="warning">Beta</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>This page is subject to change</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="outline" icon={<RefreshCw size={14} />} onClick={handleRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
<ChartIntervalDropdown
|
||||
value={interval}
|
||||
onChange={(interval) => setInterval(interval as ChartIntervalKey)}
|
||||
planId={plan?.id}
|
||||
planName={plan?.name}
|
||||
organizationSlug={organization?.slug}
|
||||
dropdownAlign="end"
|
||||
tooltipSide="left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12 mt-8">
|
||||
<DatabaseInfrastructureSection
|
||||
interval={interval}
|
||||
refreshKey={refreshKey}
|
||||
dbErrorRate={dbServiceData.errorRate}
|
||||
isLoading={dbServiceData.isLoading}
|
||||
slowQueriesCount={slowQueriesCount}
|
||||
slowQueriesLoading={slowQueriesLoading}
|
||||
/>
|
||||
|
||||
<ServiceHealthTable
|
||||
services={enabledServices.map((service) => ({
|
||||
key: service.key,
|
||||
name: service.name,
|
||||
description: '',
|
||||
reportUrl: service.hasReport ? service.reportUrl : undefined,
|
||||
logsUrl: service.logsUrl,
|
||||
}))}
|
||||
serviceData={overviewData.services}
|
||||
onBarClick={handleBarClick}
|
||||
interval={interval}
|
||||
datetimeFormat={datetimeFormat}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ObservabilityOverviewFooter />
|
||||
</ReportPadding>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
import {
|
||||
calculateErrorRate,
|
||||
calculateSuccessRate,
|
||||
getHealthStatus,
|
||||
} from './ObservabilityOverview.utils'
|
||||
|
||||
describe('calculateErrorRate', () => {
|
||||
it('returns 0 when total is 0', () => {
|
||||
const data: LogsBarChartDatum[] = [
|
||||
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 0, warning_count: 0, error_count: 0 },
|
||||
]
|
||||
|
||||
expect(calculateErrorRate(data)).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates correct error rate', () => {
|
||||
const data: LogsBarChartDatum[] = [
|
||||
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 80, warning_count: 10, error_count: 10 },
|
||||
]
|
||||
|
||||
expect(calculateErrorRate(data)).toBe(10)
|
||||
})
|
||||
|
||||
it('calculates error rate across multiple data points', () => {
|
||||
const data: LogsBarChartDatum[] = [
|
||||
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 50, warning_count: 25, error_count: 25 },
|
||||
{ timestamp: '2024-01-15T12:01:00Z', ok_count: 50, warning_count: 25, error_count: 25 },
|
||||
]
|
||||
|
||||
expect(calculateErrorRate(data)).toBe(25)
|
||||
})
|
||||
|
||||
it('handles high error rates', () => {
|
||||
const data: LogsBarChartDatum[] = [
|
||||
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 10, warning_count: 10, error_count: 80 },
|
||||
]
|
||||
|
||||
expect(calculateErrorRate(data)).toBe(80)
|
||||
})
|
||||
|
||||
it('handles empty array', () => {
|
||||
expect(calculateErrorRate([])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateSuccessRate', () => {
|
||||
it('returns 0 when total is 0', () => {
|
||||
const data: LogsBarChartDatum[] = [
|
||||
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 0, warning_count: 0, error_count: 0 },
|
||||
]
|
||||
|
||||
expect(calculateSuccessRate(data)).toBe(0)
|
||||
})
|
||||
|
||||
it('calculates correct success rate', () => {
|
||||
const data: LogsBarChartDatum[] = [
|
||||
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 80, warning_count: 10, error_count: 10 },
|
||||
]
|
||||
|
||||
expect(calculateSuccessRate(data)).toBe(80)
|
||||
})
|
||||
|
||||
it('returns 100% for all successful requests', () => {
|
||||
const data: LogsBarChartDatum[] = [
|
||||
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 100, warning_count: 0, error_count: 0 },
|
||||
]
|
||||
|
||||
expect(calculateSuccessRate(data)).toBe(100)
|
||||
})
|
||||
|
||||
it('calculates success rate across multiple data points', () => {
|
||||
const data: LogsBarChartDatum[] = [
|
||||
{ timestamp: '2024-01-15T12:00:00Z', ok_count: 70, warning_count: 15, error_count: 15 },
|
||||
{ timestamp: '2024-01-15T12:01:00Z', ok_count: 70, warning_count: 15, error_count: 15 },
|
||||
]
|
||||
|
||||
expect(calculateSuccessRate(data)).toBe(70)
|
||||
})
|
||||
|
||||
it('handles empty array', () => {
|
||||
expect(calculateSuccessRate([])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHealthStatus', () => {
|
||||
describe('unknown status', () => {
|
||||
it('returns unknown when total is 0', () => {
|
||||
const result = getHealthStatus(0, 0)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'unknown',
|
||||
color: 'muted',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns unknown when total is below 100', () => {
|
||||
const result = getHealthStatus(5, 50)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'unknown',
|
||||
color: 'muted',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns unknown when total is exactly 99', () => {
|
||||
const result = getHealthStatus(10, 99)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'unknown',
|
||||
color: 'muted',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns unknown even with high error rate when total is low', () => {
|
||||
const result = getHealthStatus(50, 20)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'unknown',
|
||||
color: 'muted',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('error status', () => {
|
||||
it('returns error when error rate is exactly 1%', () => {
|
||||
const result = getHealthStatus(1, 100)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
color: 'destructive',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error when error rate is above 1%', () => {
|
||||
const result = getHealthStatus(5, 100)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
color: 'destructive',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error when error rate is 10%', () => {
|
||||
const result = getHealthStatus(10, 200)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
color: 'destructive',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error for very high error rates', () => {
|
||||
const result = getHealthStatus(90, 1000)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
color: 'destructive',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('healthy status', () => {
|
||||
it('returns healthy when error rate is 0% with sufficient data', () => {
|
||||
const result = getHealthStatus(0, 100)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'healthy',
|
||||
color: 'brand',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns healthy when error rate is below 1%', () => {
|
||||
const result = getHealthStatus(0.5, 100)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'healthy',
|
||||
color: 'brand',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns healthy when error rate is just below 1%', () => {
|
||||
const result = getHealthStatus(0.99, 100)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'healthy',
|
||||
color: 'brand',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns healthy with 0% error rate and large total', () => {
|
||||
const result = getHealthStatus(0, 10000)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'healthy',
|
||||
color: 'brand',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles fractional error rates correctly', () => {
|
||||
const result = getHealthStatus(0.99, 1000)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'healthy',
|
||||
color: 'brand',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns unknown for very small total values', () => {
|
||||
const result = getHealthStatus(10, 1)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'unknown',
|
||||
color: 'muted',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles very large total values', () => {
|
||||
const result = getHealthStatus(0.5, 1000000)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'healthy',
|
||||
color: 'brand',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns healthy when total is exactly 100 and error rate is 0', () => {
|
||||
const result = getHealthStatus(0, 100)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'healthy',
|
||||
color: 'brand',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns error when total is exactly 100 and error rate is 1%', () => {
|
||||
const result = getHealthStatus(1, 100)
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
color: 'destructive',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
computeSuccessAndNonSuccessRates,
|
||||
sumErrors,
|
||||
sumTotal,
|
||||
sumWarnings,
|
||||
} from '../HomeNew/ProjectUsage.metrics'
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
import { useServiceHealthMetrics } from './useServiceHealthMetrics'
|
||||
|
||||
export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' | 'postgrest'
|
||||
|
||||
export type HealthStatus = 'healthy' | 'error' | 'unknown'
|
||||
|
||||
export type ServiceHealthData = {
|
||||
total: number
|
||||
errorRate: number
|
||||
successRate: number
|
||||
errorCount: number
|
||||
warningCount: number
|
||||
okCount: number
|
||||
eventChartData: LogsBarChartDatum[]
|
||||
isLoading: boolean
|
||||
error: unknown | null
|
||||
refresh: () => void
|
||||
}
|
||||
|
||||
export type OverviewData = {
|
||||
services: Record<ServiceKey, ServiceHealthData>
|
||||
aggregated: {
|
||||
totalRequests: number
|
||||
totalErrors: number
|
||||
totalWarnings: number
|
||||
overallErrorRate: number
|
||||
overallSuccessRate: number
|
||||
}
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export const calculateErrorRate = (data: LogsBarChartDatum[]): number => {
|
||||
const total = sumTotal(data)
|
||||
const errors = sumErrors(data)
|
||||
return total > 0 ? (errors / total) * 100 : 0
|
||||
}
|
||||
|
||||
export const calculateSuccessRate = (data: LogsBarChartDatum[]): number => {
|
||||
const total = sumTotal(data)
|
||||
const warnings = sumWarnings(data)
|
||||
const errors = sumErrors(data)
|
||||
const { successRate } = computeSuccessAndNonSuccessRates(total, warnings, errors)
|
||||
return successRate
|
||||
}
|
||||
|
||||
/**
|
||||
* Get health status and color based on error rate
|
||||
* - Unknown: total_requests < 100 (insufficient data)
|
||||
* - Healthy: error_rate < 1%
|
||||
* - Unhealthy: error_rate ≥ 1%
|
||||
*/
|
||||
export const getHealthStatus = (
|
||||
errorRate: number,
|
||||
total: number
|
||||
): { status: HealthStatus; color: string } => {
|
||||
if (total < 100) {
|
||||
return { status: 'unknown', color: 'muted' }
|
||||
}
|
||||
if (errorRate >= 1) {
|
||||
return { status: 'error', color: 'destructive' }
|
||||
}
|
||||
return { status: 'healthy', color: 'brand' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch and transform observability overview data for all services
|
||||
* Uses the same reliable query logic as the logs pages
|
||||
*/
|
||||
export const useObservabilityOverviewData = (
|
||||
projectRef: string,
|
||||
interval: '1hr' | '1day' | '7day',
|
||||
refreshKey: number
|
||||
): OverviewData => {
|
||||
// The new hook handles all services using logs page logic
|
||||
return useServiceHealthMetrics(projectRef, interval, refreshKey)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
export const ObservabilityOverviewFooter = () => {
|
||||
return (
|
||||
<div className="py-12 flex items-center justify-center">
|
||||
<p className="text-sm text-foreground-light">
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/troubleshooting"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground underline underline-offset-2 decoration-foreground-muted hover:decoration-foreground transition-all"
|
||||
>
|
||||
View our troubleshooting guides
|
||||
</Link>{' '}
|
||||
for solutions to common Supabase issues.{' '}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder'
|
||||
import Link from 'next/link'
|
||||
import { Button, Card, CardContent, CardFooter, CardHeader, CardTitle, Loading, cn } from 'ui'
|
||||
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
|
||||
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
import { type ServiceKey, getHealthStatus } from './ObservabilityOverview.utils'
|
||||
|
||||
const colorClassMap: Record<string, string> = {
|
||||
muted: 'bg-muted',
|
||||
destructive: 'bg-destructive',
|
||||
warning: 'bg-warning',
|
||||
brand: 'bg-brand',
|
||||
}
|
||||
|
||||
export type ServiceHealthCardProps = {
|
||||
serviceName: string
|
||||
serviceKey: ServiceKey
|
||||
total: number
|
||||
errorRate: number
|
||||
errorCount: number
|
||||
warningCount: number
|
||||
chartData: LogsBarChartDatum[]
|
||||
reportUrl?: string // undefined if feature flag disabled or no report available
|
||||
logsUrl: string
|
||||
isLoading: boolean
|
||||
onBarClick: (datum: any) => void
|
||||
datetimeFormat: string
|
||||
}
|
||||
|
||||
export const ServiceHealthCard = ({
|
||||
serviceName,
|
||||
serviceKey,
|
||||
total,
|
||||
errorRate,
|
||||
errorCount,
|
||||
warningCount,
|
||||
chartData,
|
||||
reportUrl,
|
||||
logsUrl,
|
||||
isLoading,
|
||||
onBarClick,
|
||||
datetimeFormat,
|
||||
}: ServiceHealthCardProps) => {
|
||||
const { status, color } = getHealthStatus(errorRate, total)
|
||||
|
||||
return (
|
||||
<Card className="mb-0 md:mb-0 h-full flex flex-col">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-0 border-b-0">
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="text-foreground-light flex items-center gap-2 text-xs font-medium">
|
||||
<div className={cn('w-1.5 h-1.5 rounded-full', colorClassMap[color] || 'bg-muted')} />
|
||||
{serviceName}
|
||||
</CardTitle>
|
||||
<div className="flex items-start gap-6 mt-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground text-xl">{total.toLocaleString()}</span>
|
||||
<span className="text-foreground-light text-xs">requests</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground text-xl">{errorRate.toFixed(1)}%</span>
|
||||
<span className="text-foreground-light text-xs">error rate</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end gap-4 text-foreground-light">
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-warning rounded-full" />
|
||||
<span className="heading-meta">Warn</span>
|
||||
</div>
|
||||
<span className="text-foreground text-xl">{warningCount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 bg-destructive rounded-full" />
|
||||
<span className="heading-meta">Err</span>
|
||||
</div>
|
||||
<span className="text-foreground text-xl">{errorCount.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 pt-4 flex-1 overflow-hidden max-h-[200px]">
|
||||
<Loading isFullHeight active={isLoading}>
|
||||
<LogsBarChart
|
||||
isFullHeight
|
||||
data={chartData}
|
||||
DateTimeFormat={datetimeFormat}
|
||||
onBarClick={onBarClick}
|
||||
EmptyState={
|
||||
<NoDataPlaceholder size="small" message="No data for selected period" isFullHeight />
|
||||
}
|
||||
/>
|
||||
</Loading>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="border-t pt-3 pb-4 px-4 flex gap-2">
|
||||
{reportUrl && (
|
||||
<Button type="default" size="tiny" asChild className="flex-1">
|
||||
<Link href={reportUrl}>View Report</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button type="default" size="tiny" asChild className="flex-1">
|
||||
<Link href={logsUrl}>View Logs</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { HelpCircle } from 'lucide-react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent } from 'ui'
|
||||
import { Badge, Loading, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
|
||||
|
||||
import { ButtonTooltip } from '../../ui/ButtonTooltip'
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
import { type ServiceKey, getHealthStatus } from './ObservabilityOverview.utils'
|
||||
|
||||
type ServiceConfig = {
|
||||
key: ServiceKey
|
||||
name: string
|
||||
description: string
|
||||
reportUrl?: string
|
||||
logsUrl: string
|
||||
}
|
||||
|
||||
type ServiceData = {
|
||||
total: number
|
||||
errorRate: number
|
||||
errorCount: number
|
||||
warningCount: number
|
||||
eventChartData: LogsBarChartDatum[]
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
type IntervalKey = '1hr' | '1day' | '7day'
|
||||
|
||||
export type ServiceHealthTableProps = {
|
||||
services: ServiceConfig[]
|
||||
serviceData: Record<string, ServiceData>
|
||||
onBarClick: (serviceKey: string, logsUrl: string) => (datum: LogsBarChartDatum) => void
|
||||
interval: IntervalKey
|
||||
datetimeFormat: string
|
||||
}
|
||||
|
||||
const SERVICE_DESCRIPTIONS: Record<ServiceKey, string> = {
|
||||
db: 'PostgreSQL database health and performance',
|
||||
auth: 'Authentication and user management',
|
||||
functions: 'Serverless Edge Functions execution',
|
||||
storage: 'Object storage for files and assets',
|
||||
realtime: 'WebSocket connections and broadcasts',
|
||||
postgrest: 'Auto-generated REST API for your database',
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: 'healthy' | 'error' | 'unknown'): string => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'Healthy'
|
||||
case 'error':
|
||||
return 'Unhealthy'
|
||||
case 'unknown':
|
||||
return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusTooltip = (status: 'healthy' | 'error' | 'unknown'): string => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'Error rate is below 1%'
|
||||
case 'error':
|
||||
return 'Error rate is 1% or higher'
|
||||
case 'unknown':
|
||||
return 'Insufficient data (fewer than 100 requests)'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusVariant = (
|
||||
status: 'healthy' | 'error' | 'unknown'
|
||||
): 'success' | 'warning' | 'destructive' | 'default' => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'success'
|
||||
case 'error':
|
||||
return 'destructive'
|
||||
case 'unknown':
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
type ServiceRowProps = {
|
||||
service: ServiceConfig
|
||||
data: ServiceData
|
||||
onBarClick: (datum: LogsBarChartDatum) => void
|
||||
datetimeFormat: string
|
||||
}
|
||||
|
||||
const ServiceRow = ({ service, data, onBarClick, datetimeFormat }: ServiceRowProps) => {
|
||||
const { status } = getHealthStatus(data.errorRate, data.total)
|
||||
const statusLabel = getStatusLabel(status)
|
||||
const statusVariant = getStatusVariant(status)
|
||||
const statusTooltip = getStatusTooltip(status)
|
||||
|
||||
const errorRate = data.total > 0 ? data.errorRate : 0
|
||||
const warningRate = data.total > 0 ? (data.warningCount / data.total) * 100 : 0
|
||||
|
||||
const reportUrl = service.reportUrl || service.logsUrl
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={reportUrl}
|
||||
className="block group p-4 border-b border-default last:border-b-0 hover:bg-surface-200 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground font-medium">{service.name}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className="text-foreground-lighter hover:text-foreground-light transition-colors"
|
||||
>
|
||||
<HelpCircle size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<p>{SERVICE_DESCRIPTIONS[service.key as ServiceKey] || service.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant={statusVariant}>{statusLabel}</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>{statusTooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
size="tiny"
|
||||
className="p-1.5"
|
||||
tooltip={{ content: { text: `Go to ${service.name} report` } }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-foreground-lighter group-hover:text-foreground"
|
||||
/>
|
||||
</ButtonTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-16" onClick={(e) => e.preventDefault()}>
|
||||
<Loading active={data.isLoading}>
|
||||
{data.isLoading ? (
|
||||
<div />
|
||||
) : (
|
||||
<LogsBarChart
|
||||
data={data.eventChartData}
|
||||
DateTimeFormat={datetimeFormat}
|
||||
onBarClick={onBarClick}
|
||||
EmptyState={
|
||||
<div className="flex items-center justify-center h-full text-xs text-foreground-lighter">
|
||||
No data
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Loading>
|
||||
</div>
|
||||
|
||||
{data.total > 0 && (
|
||||
<div className="flex items-center justify-center mt-2 text-xs text-foreground-lighter gap-4 font-mono tabular-nums tracking-tight">
|
||||
{errorRate > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-destructive" />
|
||||
{errorRate.toFixed(2)}% errors
|
||||
</span>
|
||||
)}
|
||||
{warningRate > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-warning" />
|
||||
{warningRate.toFixed(2)}% warnings
|
||||
</span>
|
||||
)}
|
||||
{errorRate === 0 && warningRate === 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-brand" />
|
||||
0% errors
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export const ServiceHealthTable = ({
|
||||
services,
|
||||
serviceData,
|
||||
onBarClick,
|
||||
datetimeFormat,
|
||||
}: ServiceHealthTableProps) => {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="heading-section mb-4">Service Health</h2>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{services.map((service) => {
|
||||
const data = serviceData[service.key]
|
||||
if (!data) return null
|
||||
|
||||
return (
|
||||
<ServiceRow
|
||||
key={service.key}
|
||||
service={service}
|
||||
data={data}
|
||||
onBarClick={onBarClick(service.key, service.logsUrl)}
|
||||
datetimeFormat={datetimeFormat}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { get } from 'data/fetchers'
|
||||
import { generateRegexpWhere } from '../Reports/Reports.constants'
|
||||
import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics'
|
||||
|
||||
type PostgrestMetricsVariables = {
|
||||
projectRef: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
interval: '1hr' | '1day' | '7day'
|
||||
}
|
||||
|
||||
const getIntervalTrunc = (interval: '1hr' | '1day' | '7day') => {
|
||||
switch (interval) {
|
||||
case '1hr':
|
||||
return 'minute' // 1-minute buckets for 1 hour
|
||||
case '1day':
|
||||
return 'hour' // 1-hour buckets for 1 day
|
||||
case '7day':
|
||||
return 'day' // 1-day buckets for 7 days
|
||||
default:
|
||||
return 'hour'
|
||||
}
|
||||
}
|
||||
|
||||
const POSTGREST_METRICS_SQL = (interval: '1hr' | '1day' | '7day') => {
|
||||
const truncInterval = getIntervalTrunc(interval)
|
||||
|
||||
return `
|
||||
-- postgrest-overview-metrics
|
||||
select
|
||||
cast(timestamp_trunc(t.timestamp, ${truncInterval}) as datetime) as timestamp,
|
||||
countif(response.status_code < 300) as ok_count,
|
||||
countif(response.status_code >= 300 and response.status_code < 400) as warning_count,
|
||||
countif(response.status_code >= 400) as error_count
|
||||
FROM edge_logs t
|
||||
cross join unnest(metadata) as m
|
||||
cross join unnest(m.response) as response
|
||||
cross join unnest(m.request) as request
|
||||
WHERE
|
||||
request.path like '/rest/%'
|
||||
GROUP BY
|
||||
timestamp
|
||||
ORDER BY
|
||||
timestamp ASC
|
||||
`
|
||||
}
|
||||
|
||||
type MetricsRow = {
|
||||
timestamp: string
|
||||
ok_count: number
|
||||
warning_count: number
|
||||
error_count: number
|
||||
}
|
||||
|
||||
async function fetchPostgrestMetrics(
|
||||
{ projectRef, startDate, endDate, interval }: PostgrestMetricsVariables,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const sql = POSTGREST_METRICS_SQL(interval)
|
||||
|
||||
const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, {
|
||||
params: {
|
||||
path: { ref: projectRef },
|
||||
query: {
|
||||
sql,
|
||||
iso_timestamp_start: startDate,
|
||||
iso_timestamp_end: endDate,
|
||||
},
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (error || data?.error) {
|
||||
throw error || data?.error
|
||||
}
|
||||
|
||||
return (data?.result || []) as MetricsRow[]
|
||||
}
|
||||
|
||||
export const usePostgrestOverviewMetrics = (
|
||||
{ projectRef, startDate, endDate, interval }: PostgrestMetricsVariables,
|
||||
options?: { enabled?: boolean }
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ['postgrest-overview-metrics', projectRef, startDate, endDate, interval],
|
||||
queryFn: ({ signal }) =>
|
||||
fetchPostgrestMetrics({ projectRef, startDate, endDate, interval }, signal),
|
||||
enabled: (options?.enabled ?? true) && Boolean(projectRef),
|
||||
staleTime: 1000 * 60,
|
||||
})
|
||||
}
|
||||
|
||||
export const transformPostgrestMetrics = (rows: MetricsRow[]): LogsBarChartDatum[] => {
|
||||
return rows.map((row) => ({
|
||||
timestamp: row.timestamp,
|
||||
ok_count: row.ok_count,
|
||||
warning_count: row.warning_count,
|
||||
error_count: row.error_count,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import useDbQuery from 'hooks/analytics/useDbQuery'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export const useSlowQueriesCount = (projectRef?: string, refreshKey: number = 0) => {
|
||||
// SQL to count queries with total execution time > 1000ms (1 second)
|
||||
// refreshKey is used in useMemo to force recomputation when refresh is triggered
|
||||
const sql = useMemo(
|
||||
() => `
|
||||
-- observability-slow-queries-count
|
||||
set search_path to public, extensions;
|
||||
|
||||
SELECT
|
||||
count(*)::int as slow_queries_count
|
||||
FROM pg_stat_statements
|
||||
WHERE total_exec_time + total_plan_time > 1000;
|
||||
`,
|
||||
[refreshKey]
|
||||
)
|
||||
|
||||
const { data, isLoading, error } = useDbQuery({
|
||||
sql,
|
||||
})
|
||||
|
||||
const slowQueriesCount = useMemo(() => {
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return 0
|
||||
}
|
||||
return data[0]?.slow_queries_count ?? 0
|
||||
}, [data])
|
||||
|
||||
return {
|
||||
slowQueriesCount,
|
||||
isLoading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,34 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useFlag, useParams } from 'common'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { CreateReportModal } from 'components/interfaces/Reports/CreateReportModal'
|
||||
import { UpdateCustomReportModal } from 'components/interfaces/Reports/UpdateModal'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { useContentDeleteMutation } from 'data/content/content-delete-mutation'
|
||||
import { Content, useContentQuery } from 'data/content/content-query'
|
||||
import { Content, ContentBase, useContentQuery } from 'data/content/content-query'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { useProfile } from 'lib/profile'
|
||||
import { Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Dashboards } from 'types'
|
||||
import { Menu, cn } from 'ui'
|
||||
import { InnerSideBarEmptyPanel } from 'ui-patterns'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { ObservabilityMenuItem } from './ObservabilityMenuItem'
|
||||
import { useQueryState, parseAsBoolean } from 'nuqs'
|
||||
|
||||
const ObservabilityMenu = () => {
|
||||
const router = useRouter()
|
||||
const { profile } = useProfile()
|
||||
const { ref, id } = useParams()
|
||||
const pageKey = (id || router.pathname.split('/')[4]) as string
|
||||
const pageKey = (id || router.pathname.split('/')[4] || 'observability') as string
|
||||
const showOverview = useFlag('observabilityOverview')
|
||||
const authEnabled = useFlag('authreportv2')
|
||||
const edgeFnEnabled = useFlag('edgefunctionreport')
|
||||
const realtimeEnabled = useFlag('realtimeReport')
|
||||
@@ -89,10 +91,17 @@ const ObservabilityMenu = () => {
|
||||
deleteReport({ projectRef: ref, ids: [selectedReportToDelete.id] })
|
||||
}
|
||||
|
||||
function isReportContent(c: Content): c is ContentBase & {
|
||||
type: 'report'
|
||||
content: Dashboards.Content
|
||||
} {
|
||||
return c.type === 'report'
|
||||
}
|
||||
|
||||
function getReportMenuItems() {
|
||||
if (!content) return []
|
||||
|
||||
const reports = content?.content.filter((c) => c.type === 'report')
|
||||
const reports = content?.content.filter(isReportContent)
|
||||
|
||||
const sortedReports = reports?.sort((a, b) => {
|
||||
if (a.name < b.name) {
|
||||
@@ -121,9 +130,23 @@ const ObservabilityMenu = () => {
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: 'Performance Reports',
|
||||
key: 'performance-reports',
|
||||
title: 'GENERAL',
|
||||
key: 'general-section',
|
||||
items: [
|
||||
...(showOverview
|
||||
? [
|
||||
{
|
||||
name: 'Overview',
|
||||
key: 'observability',
|
||||
url: `/project/${ref}/observability${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Query Performance',
|
||||
key: 'query-performance',
|
||||
url: `/project/${ref}/observability/query-performance${preservedQueryParams}`,
|
||||
},
|
||||
...(IS_PLATFORM
|
||||
? [
|
||||
{
|
||||
@@ -133,11 +156,21 @@ const ObservabilityMenu = () => {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Query Performance',
|
||||
key: 'query-performance',
|
||||
url: `/project/${ref}/observability/query-performance${preservedQueryParams}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'PRODUCT',
|
||||
key: 'product-section',
|
||||
items: [
|
||||
...(IS_PLATFORM
|
||||
? [
|
||||
{
|
||||
name: 'Database',
|
||||
key: 'database',
|
||||
url: `/project/${ref}/observability/database${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(postgrestReportEnabled
|
||||
? [
|
||||
{
|
||||
@@ -147,12 +180,6 @@ const ObservabilityMenu = () => {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Product Reports',
|
||||
key: 'product-reports',
|
||||
items: [
|
||||
...(authEnabled
|
||||
? [
|
||||
{
|
||||
@@ -162,15 +189,6 @@ const ObservabilityMenu = () => {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(IS_PLATFORM
|
||||
? [
|
||||
{
|
||||
name: 'Database',
|
||||
key: 'database',
|
||||
url: `/project/${ref}/observability/database${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(edgeFnEnabled
|
||||
? [
|
||||
{
|
||||
@@ -180,15 +198,6 @@ const ObservabilityMenu = () => {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(realtimeEnabled
|
||||
? [
|
||||
{
|
||||
name: 'Realtime',
|
||||
key: 'realtime',
|
||||
url: `/project/${ref}/observability/realtime${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(storageEnabled
|
||||
? [
|
||||
{
|
||||
@@ -198,6 +207,15 @@ const ObservabilityMenu = () => {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(realtimeEnabled
|
||||
? [
|
||||
{
|
||||
name: 'Realtime',
|
||||
key: 'realtime',
|
||||
url: `/project/${ref}/observability/realtime${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -212,83 +230,6 @@ const ObservabilityMenu = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-y-6">
|
||||
{IS_PLATFORM && (
|
||||
<div className="mx-2">
|
||||
<Menu.Group
|
||||
title={
|
||||
<span className="flex w-full items-center justify-between relative h-6">
|
||||
<span className="uppercase font-mono">Custom Reports</span>
|
||||
{reportMenuItems.length > 0 && (
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Plus />}
|
||||
disabled={!canCreateCustomReport}
|
||||
className="flex items-center justify-center h-6 w-6 absolute top-0 -right-1"
|
||||
onClick={() => {
|
||||
setShowNewReportModal(true)
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canCreateCustomReport
|
||||
? 'You need additional permissions to create custom reports'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{reportMenuItems.length === 0 ? (
|
||||
<div className="px-2">
|
||||
<InnerSideBarEmptyPanel
|
||||
title="No custom reports yet"
|
||||
description="Create and save custom reports to track your project metrics"
|
||||
actions={
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
icon={<Plus />}
|
||||
disabled={!canCreateCustomReport}
|
||||
onClick={() => {
|
||||
setShowNewReportModal(true)
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canCreateCustomReport
|
||||
? 'You need additional permissions to create custom reports'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
New custom report
|
||||
</ButtonTooltip>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{reportMenuItems.map((item) => (
|
||||
<ObservabilityMenuItem
|
||||
key={item.id}
|
||||
item={item as any}
|
||||
pageKey={pageKey}
|
||||
onSelectEdit={() => {
|
||||
setSelectedReportToUpdate(item.report)
|
||||
}}
|
||||
onSelectDelete={() => {
|
||||
setSelectedReportToDelete(item.report)
|
||||
setDeleteModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menuItems.map((item, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<div className="h-px w-full bg-border-overlay first:hidden" />
|
||||
@@ -323,6 +264,86 @@ const ObservabilityMenu = () => {
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{IS_PLATFORM && (
|
||||
<Fragment>
|
||||
<div className="h-px w-full bg-border-overlay" />
|
||||
<div className="mx-2">
|
||||
<Menu.Group
|
||||
title={
|
||||
<span className="flex w-full items-center justify-between relative h-6">
|
||||
<span className="uppercase font-mono">Custom Reports</span>
|
||||
{reportMenuItems.length > 0 && (
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Plus />}
|
||||
disabled={!canCreateCustomReport}
|
||||
className="flex items-center justify-center h-6 w-6 absolute top-0 -right-1"
|
||||
onClick={() => {
|
||||
setShowNewReportModal(true)
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canCreateCustomReport
|
||||
? 'You need additional permissions to create custom reports'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{reportMenuItems.length === 0 ? (
|
||||
<div className="px-2">
|
||||
<InnerSideBarEmptyPanel
|
||||
title="No custom reports yet"
|
||||
description="Create and save custom reports to track your project metrics"
|
||||
actions={
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
icon={<Plus />}
|
||||
disabled={!canCreateCustomReport}
|
||||
onClick={() => {
|
||||
setShowNewReportModal(true)
|
||||
}}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canCreateCustomReport
|
||||
? 'You need additional permissions to create custom reports'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
New custom report
|
||||
</ButtonTooltip>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{reportMenuItems.map((item) => (
|
||||
<ObservabilityMenuItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
pageKey={pageKey}
|
||||
onSelectEdit={() => {
|
||||
setSelectedReportToUpdate(item.report)
|
||||
}}
|
||||
onSelectDelete={() => {
|
||||
setSelectedReportToDelete(item.report)
|
||||
setDeleteModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<UpdateCustomReportModal
|
||||
onCancel={() => setSelectedReportToUpdate(undefined)}
|
||||
selectedReport={selectedReportToUpdate}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { InlineLink } from 'components/ui/InlineLink'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { CHART_INTERVALS, LOG_RETENTION } from './logs.utils'
|
||||
|
||||
type PlanId = keyof typeof LOG_RETENTION
|
||||
|
||||
interface ChartIntervalDropdownProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
planId?: string
|
||||
planName?: string
|
||||
organizationSlug?: string
|
||||
dropdownAlign?: 'start' | 'center' | 'end'
|
||||
tooltipSide?: 'left' | 'right' | 'top' | 'bottom'
|
||||
}
|
||||
|
||||
export const ChartIntervalDropdown = ({
|
||||
value,
|
||||
onChange,
|
||||
planId = 'free',
|
||||
planName,
|
||||
organizationSlug,
|
||||
dropdownAlign = 'start',
|
||||
tooltipSide = 'right',
|
||||
}: ChartIntervalDropdownProps) => {
|
||||
const selectedInterval = CHART_INTERVALS.find((i) => i.key === value) || CHART_INTERVALS[1]
|
||||
const normalizedPlanId = (planId && planId in LOG_RETENTION ? planId : 'free') as PlanId
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="default" iconRight={<ChevronDown size={14} />}>
|
||||
<span>{selectedInterval.label}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align={dropdownAlign} className="w-40">
|
||||
<DropdownMenuRadioGroup value={value} onValueChange={onChange}>
|
||||
{CHART_INTERVALS.map((i) => {
|
||||
const disabled = !i.availableIn?.includes(normalizedPlanId)
|
||||
|
||||
if (disabled) {
|
||||
const retentionDuration = LOG_RETENTION[normalizedPlanId]
|
||||
return (
|
||||
<Tooltip key={i.key}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuRadioItem disabled value={i.key} className="!pointer-events-auto">
|
||||
{i.label}
|
||||
</DropdownMenuRadioItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide}>
|
||||
<p>
|
||||
{planName} plan only includes up to {retentionDuration} day
|
||||
{retentionDuration > 1 ? 's' : ''} of log retention
|
||||
</p>
|
||||
<p className="text-foreground-light">
|
||||
{organizationSlug ? (
|
||||
<>
|
||||
<InlineLink
|
||||
className="text-foreground-light hover:text-foreground"
|
||||
href={`/org/${organizationSlug}/billing?panel=subscriptionPlan`}
|
||||
>
|
||||
Upgrade your plan
|
||||
</InlineLink>{' '}
|
||||
to increase log retention and view statistics for the{' '}
|
||||
{i.label.toLowerCase()}
|
||||
</>
|
||||
) : (
|
||||
`Upgrade your plan to increase log retention and view statistics for the ${i.label.toLowerCase()}`
|
||||
)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<DropdownMenuRadioItem key={i.key} value={i.key}>
|
||||
{i.label}
|
||||
</DropdownMenuRadioItem>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ChartIntervals } from 'types'
|
||||
|
||||
export const LOG_RETENTION = {
|
||||
free: 1,
|
||||
pro: 7,
|
||||
team: 28,
|
||||
enterprise: 90,
|
||||
platform: 1,
|
||||
}
|
||||
|
||||
export const CHART_INTERVALS: ChartIntervals[] = [
|
||||
{
|
||||
key: '1hr',
|
||||
label: 'Last 60 minutes',
|
||||
startValue: 1,
|
||||
startUnit: 'hour',
|
||||
format: 'MMM D, h:mma',
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
|
||||
},
|
||||
{
|
||||
key: '1day',
|
||||
label: 'Last 24 hours',
|
||||
startValue: 24,
|
||||
startUnit: 'hour',
|
||||
format: 'MMM D, ha',
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'],
|
||||
},
|
||||
{
|
||||
key: '7day',
|
||||
label: 'Last 7 days',
|
||||
startValue: 7,
|
||||
startUnit: 'day',
|
||||
format: 'MMM D',
|
||||
availableIn: ['pro', 'team', 'enterprise'],
|
||||
},
|
||||
]
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { paths } from 'api-types'
|
||||
import { get, handleError } from 'data/fetchers'
|
||||
import { UseCustomQueryOptions } from 'types'
|
||||
|
||||
import type { AnalyticsInterval } from './constants'
|
||||
import { analyticsKeys } from './keys'
|
||||
|
||||
@@ -14,7 +14,7 @@ export type InfraMonitoringSeriesMetadata = {
|
||||
yAxisLimit: number
|
||||
format: string
|
||||
total: number
|
||||
totalAverage: number
|
||||
totalAverage: number | string
|
||||
}
|
||||
|
||||
// TODO(raulb): Remove InfraMonitoringSingleResponse once API always returns multi-attribute format.
|
||||
|
||||
@@ -2,8 +2,9 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useParams, useFlag } from 'common'
|
||||
import { CreateReportModal } from 'components/interfaces/Reports/CreateReportModal'
|
||||
import { ObservabilityOverview } from 'components/interfaces/Observability/ObservabilityOverview'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import ObservabilityLayout from 'components/layouts/ObservabilityLayout/ObservabilityLayout'
|
||||
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
|
||||
@@ -19,6 +20,7 @@ export const UserReportPage: NextPageWithLayout = () => {
|
||||
const { ref } = useParams()
|
||||
|
||||
const { profile } = useProfile()
|
||||
const showOverview = useFlag('observabilityOverview')
|
||||
const [showCreateReportModal, setShowCreateReportModal] = useQueryState(
|
||||
'newReport',
|
||||
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
|
||||
@@ -35,13 +37,14 @@ export const UserReportPage: NextPageWithLayout = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuccess) return
|
||||
if (showOverview) return // Don't redirect if overview is enabled
|
||||
|
||||
const reports = data.content
|
||||
.filter((x) => x.type === 'report')
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
if (reports.length >= 1) router.push(`/project/${ref}/observability/${reports[0].id}`)
|
||||
if (reports.length === 0) router.push(`/project/${ref}/observability/api-overview`)
|
||||
}, [isSuccess, data, router, ref])
|
||||
}, [isSuccess, data, router, ref, showOverview])
|
||||
|
||||
const { can: canCreateReport } = useAsyncCheckPermissions(
|
||||
PermissionAction.CREATE,
|
||||
@@ -52,6 +55,11 @@ export const UserReportPage: NextPageWithLayout = () => {
|
||||
}
|
||||
)
|
||||
|
||||
// Show overview page if feature flag is enabled
|
||||
if (showOverview) {
|
||||
return <ObservabilityOverview />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
{isLoading ? (
|
||||
|
||||
@@ -76,14 +76,14 @@ export const componentPages: SidebarNavGroup = {
|
||||
},
|
||||
{
|
||||
title: 'Dropzone',
|
||||
supportedFrameworks: ['nextjs', 'react-router', 'tanstack', 'react'],
|
||||
supportedFrameworks: ['nextjs', 'react-router', 'tanstack', 'react', 'vue', 'nuxtjs'],
|
||||
href: '/docs/nextjs/dropzone',
|
||||
items: [],
|
||||
commandItemLabel: 'Dropzone (File Upload)',
|
||||
},
|
||||
{
|
||||
title: 'Realtime Cursor',
|
||||
supportedFrameworks: ['nextjs', 'react-router', 'tanstack', 'react'],
|
||||
supportedFrameworks: ['nextjs', 'react-router', 'tanstack', 'react', 'vue', 'nuxtjs'],
|
||||
href: '/docs/nextjs/realtime-cursor',
|
||||
items: [],
|
||||
commandItemLabel: 'Realtime Cursor',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: Dropzone (File Upload)
|
||||
description: Displays a control for easier uploading of files directly to Supabase Storage
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
name="dropzone-demo"
|
||||
description="A file upload dropzone component that allows users to drag-and-drop files or select them manually. It supports multiple files, displays upload progress, previews images, and shows validation errors."
|
||||
showCode={false}
|
||||
/>
|
||||
|
||||
## Installation
|
||||
|
||||
<BlockItem
|
||||
name="dropzone-nuxtjs"
|
||||
description="Displays a control for easier uploading of files directly to Supabase Storage"
|
||||
/>
|
||||
|
||||
## Folder structure
|
||||
|
||||
This block assumes that you have already installed a Supabase client for Nuxt from the previous step.
|
||||
|
||||
<RegistryBlock itemName="dropzone-nuxtjs" />
|
||||
|
||||
## Introduction
|
||||
|
||||
Uploading files should be easy—this component handles the tricky parts for you.
|
||||
|
||||
The File Upload component makes it easy to add file uploads to your app, with built-in support for drag-and-drop, file type restrictions, image previews, and configurable limits on file size and number of files. All the essentials, ready to go.
|
||||
|
||||
**Features**
|
||||
|
||||
- Drag-and-drop support
|
||||
- Multiple file uploads
|
||||
- File size and count limits
|
||||
- Image previews for supported file types
|
||||
- MIME type restrictions
|
||||
- Invalid file handling
|
||||
- Success and error states with clear feedback
|
||||
|
||||
## Usage
|
||||
|
||||
- Simply add this `<Dropzone />` component to your page and it will handle the rest.
|
||||
|
||||
```html
|
||||
<script setup>
|
||||
import { Dropzone, DropzoneEmptyState, DropzoneContent } from '@/components/dropzone'
|
||||
import { useSupabaseUpload } from '@/composables/use-supabase-upload'
|
||||
|
||||
const upload = useSupabaseUpload({
|
||||
bucketName: 'test',
|
||||
path: 'test',
|
||||
allowedMimeTypes: ['image/*'],
|
||||
maxFiles: 2,
|
||||
maxFileSize: 1000 * 1000 * 10, // 10MB,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropzone v-bind="upload">
|
||||
<DropzoneEmptyState />
|
||||
<DropzoneContent />
|
||||
</Dropzone>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------------ | ---------- | ---------- | ---------------------------------------------------- |
|
||||
| `bucketName` | `string` | `null` | The name of the Supabase Storage bucket to upload to |
|
||||
| `path` | `string` | `null` | The path or subfolder to upload the file to |
|
||||
| `allowedMimeTypes` | `string[]` | `[]` | The MIME types to allow for upload |
|
||||
| `maxFiles` | `number` | `1` | Maximum number of files to upload |
|
||||
| `maxFileSize` | `number` | `Infinity` | Maximum file size in bytes |
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Creating buckets](https://supabase.com/docs/guides/storage/buckets/creating-buckets)
|
||||
- [Access control](https://supabase.com/docs/guides/storage/security/access-control)
|
||||
- [Standard uploads](https://supabase.com/docs/guides/storage/uploads/standard-uploads)
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: Realtime Cursor
|
||||
description: Real-time cursor sharing for collaborative applications
|
||||
---
|
||||
|
||||
<div className="flex flex-row -space-x-px w-full">
|
||||
<BlockPreview name="realtime-cursor-demo" isPair />
|
||||
<BlockPreview name="realtime-cursor-demo" isPair />
|
||||
</div>
|
||||
|
||||
## Installation
|
||||
|
||||
<BlockItem
|
||||
name="realtime-cursor-nuxtjs"
|
||||
description="Renders realtime cursors from other users in a room."
|
||||
/>
|
||||
|
||||
## Folder structure
|
||||
|
||||
This block assumes that you have already installed a Supabase client for Nuxt from the previous step.
|
||||
|
||||
<RegistryBlock itemName="realtime-cursor-nuxtjs" />
|
||||
|
||||
## Introduction
|
||||
|
||||
The Realtime Cursors component lets users share their cursor position with others in the same room—perfect for real-time collaboration. It handles all the setup and boilerplate for you, so you can add it to your app with minimal effort.
|
||||
|
||||
**Features**
|
||||
|
||||
- Broadcast cursor position to other users in the same room
|
||||
- Customizable cursor appearance
|
||||
- Presence detection (automatically joins/leaves users)
|
||||
- Low-latency updates using Supabase Realtime
|
||||
- Room-based isolation for scoped collaboration
|
||||
|
||||
## Usage
|
||||
|
||||
The Realtime Cursor component is designed to be used in a room. It can be used to build real-time collaborative applications. Add the `<RealtimeCursors />` component to your page and it will render realtime cursors from other users in the room.
|
||||
|
||||
```html
|
||||
<script setup lang="ts">
|
||||
import RealtimeCursors from '@/components/RealtimeCursors.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full min-h-screen">
|
||||
<RealtimeCursors roomName="macrodata_refinement_office" username="Mark Scout" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ---------- | -------- | ---------------------------------------------------------- |
|
||||
| `roomName` | `string` | Unique identifier for the shared room or session. |
|
||||
| `username` | `string` | Name of the current user; used to track and label cursors. |
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Realtime Broadcast](https://supabase.com/docs/guides/realtime/broadcast)
|
||||
- [Realtime authorization](https://supabase.com/docs/guides/realtime/authorization)
|
||||
|
||||
### Smoother cursors
|
||||
|
||||
While our Realtime Cursor component aims to keep things simple and lightweight, you may want to add smoother cursor animations for a more polished experience. Libraries like [perfect-cursors](https://github.com/steveruizok/perfect-cursors) can be integrated to add sophisticated interpolation between cursor positions. This is especially useful when dealing with network latency, as it creates fluid cursor movements even when position updates are received at longer intervals (e.g., every 50-80ms). The library handles the complex math of creating natural-looking cursor paths while maintaining performance.
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: Dropzone (File Upload)
|
||||
description: Displays a control for easier uploading of files directly to Supabase Storage
|
||||
---
|
||||
|
||||
<ComponentPreview
|
||||
name="dropzone-demo"
|
||||
description="A file upload dropzone component that allows users to drag-and-drop files or select them manually. It supports multiple files, displays upload progress, previews images, and shows validation errors."
|
||||
showCode={false}
|
||||
/>
|
||||
|
||||
## Installation
|
||||
|
||||
<BlockItem
|
||||
name="dropzone-vue"
|
||||
description="Displays a control for easier uploading of files directly to Supabase Storage"
|
||||
/>
|
||||
|
||||
## Folder structure
|
||||
|
||||
This block assumes that you have already installed a Supabase client for Vue from the previous step.
|
||||
|
||||
<RegistryBlock itemName="dropzone-vue" />
|
||||
|
||||
## Introduction
|
||||
|
||||
Uploading files should be easy—this component handles the tricky parts for you.
|
||||
|
||||
The File Upload component makes it easy to add file uploads to your app, with built-in support for drag-and-drop, file type restrictions, image previews, and configurable limits on file size and number of files. All the essentials, ready to go.
|
||||
|
||||
**Features**
|
||||
|
||||
- Drag-and-drop support
|
||||
- Multiple file uploads
|
||||
- File size and count limits
|
||||
- Image previews for supported file types
|
||||
- MIME type restrictions
|
||||
- Invalid file handling
|
||||
- Success and error states with clear feedback
|
||||
|
||||
## Usage
|
||||
|
||||
- Simply add this `<Dropzone />` component to your page and it will handle the rest.
|
||||
|
||||
```html
|
||||
<script setup>
|
||||
import { Dropzone, DropzoneEmptyState, DropzoneContent } from '@/components/dropzone'
|
||||
import { useSupabaseUpload } from '@/composables/use-supabase-upload'
|
||||
|
||||
const upload = useSupabaseUpload({
|
||||
bucketName: 'test',
|
||||
path: 'test',
|
||||
allowedMimeTypes: ['image/*'],
|
||||
maxFiles: 2,
|
||||
maxFileSize: 1000 * 1000 * 10, // 10MB,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dropzone v-bind="upload">
|
||||
<DropzoneEmptyState />
|
||||
<DropzoneContent />
|
||||
</Dropzone>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
| ------------------ | ---------- | ---------- | ---------------------------------------------------- |
|
||||
| `bucketName` | `string` | `null` | The name of the Supabase Storage bucket to upload to |
|
||||
| `path` | `string` | `null` | The path or subfolder to upload the file to |
|
||||
| `allowedMimeTypes` | `string[]` | `[]` | The MIME types to allow for upload |
|
||||
| `maxFiles` | `number` | `1` | Maximum number of files to upload |
|
||||
| `maxFileSize` | `number` | `Infinity` | Maximum file size in bytes |
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Creating buckets](https://supabase.com/docs/guides/storage/buckets/creating-buckets)
|
||||
- [Access control](https://supabase.com/docs/guides/storage/security/access-control)
|
||||
- [Standard uploads](https://supabase.com/docs/guides/storage/uploads/standard-uploads)
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: Realtime Cursor
|
||||
description: Real-time cursor sharing for collaborative applications
|
||||
---
|
||||
|
||||
<div className="flex flex-row -space-x-px w-full">
|
||||
<BlockPreview name="realtime-cursor-demo" isPair />
|
||||
<BlockPreview name="realtime-cursor-demo" isPair />
|
||||
</div>
|
||||
|
||||
## Installation
|
||||
|
||||
<BlockItem
|
||||
name="realtime-cursor-vue"
|
||||
description="Renders realtime cursors from other users in a room."
|
||||
/>
|
||||
|
||||
## Folder structure
|
||||
|
||||
This block assumes that you have already installed a Supabase client for Vue from the previous step.
|
||||
|
||||
<RegistryBlock itemName="realtime-cursor-vue" />
|
||||
|
||||
## Introduction
|
||||
|
||||
The Realtime Cursors component lets users share their cursor position with others in the same room—perfect for real-time collaboration. It handles all the setup and boilerplate for you, so you can add it to your app with minimal effort.
|
||||
|
||||
**Features**
|
||||
|
||||
- Broadcast cursor position to other users in the same room
|
||||
- Customizable cursor appearance
|
||||
- Presence detection (automatically joins/leaves users)
|
||||
- Low-latency updates using Supabase Realtime
|
||||
- Room-based isolation for scoped collaboration
|
||||
|
||||
## Usage
|
||||
|
||||
The Realtime Cursor component is designed to be used in a room. It can be used to build real-time collaborative applications. Add the `<RealtimeCursors />` component to your page and it will render realtime cursors from other users in the room.
|
||||
|
||||
```html
|
||||
<script setup lang="ts">
|
||||
import RealtimeCursors from '@/components/RealtimeCursors.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full min-h-screen">
|
||||
<RealtimeCursors roomName="macrodata_refinement_office" username="Mark Scout" />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ---------- | -------- | ---------------------------------------------------------- |
|
||||
| `roomName` | `string` | Unique identifier for the shared room or session. |
|
||||
| `username` | `string` | Name of the current user; used to track and label cursors. |
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Realtime Broadcast](https://supabase.com/docs/guides/realtime/broadcast)
|
||||
- [Realtime authorization](https://supabase.com/docs/guides/realtime/authorization)
|
||||
|
||||
### Smoother cursors
|
||||
|
||||
While our Realtime Cursor component aims to keep things simple and lightweight, you may want to add smoother cursor animations for a more polished experience. Libraries like [perfect-cursors](https://github.com/steveruizok/perfect-cursors) can be integrated to add sophisticated interpolation between cursor positions. This is especially useful when dealing with network latency, as it creates fluid cursor movements even when position updates are received at longer intervals (e.g., every 50-80ms). The library handles the complex math of creating natural-looking cursor paths while maintaining performance.
|
||||
@@ -1,5 +1,5 @@
|
||||
# Supabase UI Library
|
||||
Last updated: 2025-11-20T11:14:14.146Z
|
||||
Last updated: 2026-01-22T19:37:07.586Z
|
||||
|
||||
## Overview
|
||||
Library of components for your project. The components integrate with Supabase and are shadcn compatible.
|
||||
@@ -33,8 +33,12 @@ Library of components for your project. The components integrate with Supabase a
|
||||
- Social authentication block for Next.js
|
||||
- [Supabase Client Libraries](https://supabase.com/ui/docs/nuxtjs/client)
|
||||
- Supabase client for Nuxt.js
|
||||
- [Dropzone (File Upload)](https://supabase.com/ui/docs/nuxtjs/dropzone)
|
||||
- Displays a control for easier uploading of files directly to Supabase Storage
|
||||
- [Password-based Authentication](https://supabase.com/ui/docs/nuxtjs/password-based-auth)
|
||||
- Password-based authentication block for Nuxt.js
|
||||
- [Realtime Cursor](https://supabase.com/ui/docs/nuxtjs/realtime-cursor)
|
||||
- Real-time cursor sharing for collaborative applications
|
||||
- [Social Authentication](https://supabase.com/ui/docs/nuxtjs/social-auth)
|
||||
- Social authentication block for Nuxt.js
|
||||
- [Platform Kit](https://supabase.com/ui/docs/platform/platform-kit)
|
||||
@@ -89,7 +93,11 @@ Library of components for your project. The components integrate with Supabase a
|
||||
- Social authentication block for Tanstack Start
|
||||
- [Supabase Client Libraries](https://supabase.com/ui/docs/vue/client)
|
||||
- Supabase client for Vue Single Page Applications
|
||||
- [Dropzone (File Upload)](https://supabase.com/ui/docs/vue/dropzone)
|
||||
- Displays a control for easier uploading of files directly to Supabase Storage
|
||||
- [Password-based Authentication](https://supabase.com/ui/docs/vue/password-based-auth)
|
||||
- Password-based authentication block for Vue Single Page Applications
|
||||
- [Realtime Cursor](https://supabase.com/ui/docs/vue/realtime-cursor)
|
||||
- Real-time cursor sharing for collaborative applications
|
||||
- [Social Authentication](https://supabase.com/ui/docs/vue/social-auth)
|
||||
- Social authentication block for Vue Single Page Applications
|
||||
@@ -33,7 +33,7 @@
|
||||
},
|
||||
{
|
||||
"path": "registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc",
|
||||
"content": "---\ndescription: Coding rules for Supabase Edge Functions\nalwaysApply: false\n---\n\n# Writing Supabase Edge Functions\n\nYou're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices:\n\n## Guidelines\n\n1. Try to use Web APIs and Deno’s core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)\n2. If you are reusing utility methods between Edge Functions, add them to `supabase/functions/_shared` and import using a relative path. Do NOT have cross dependencies between Edge Functions.\n3. Do NOT use bare specifiers when importing dependecnies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`.\n4. For external imports, always define a version. For example, `npm:@express` should be written as `npm:express@4.18.2`.\n5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from @`deno.land/x` , `esm.sh` and @`unpkg.com` . If you have a package from one of those CDNs, you can replace the CDN hostname with `npm:` specifier.\n6. You can also use Node built-in APIs. You will need to import them using `node:` specifier. For example, to import Node process: `import process from \"node:process\". Use Node APIs when you find gaps in Deno APIs.\n7. Do NOT use `import { serve } from \"https://deno.land/std@0.168.0/http/server.ts\"`. Instead use the built-in `Deno.serve`.\n8. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:\n - SUPABASE_URL\n - SUPABASE_PUBLISHABLE_OR_ANON_KEY\n - SUPABASE_SERVICE_ROLE_KEY\n - SUPABASE_DB_URL\n9. To set other environment variables (ie. secrets) users can put them in a env file and run the `supabase secrets set --env-file path/to/env-file`\n10. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly.\n11. File write operations are ONLY permitted on `/tmp` directory. You can use either Deno or Node File APIs.\n12. Use `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context.\n\n## Example Templates\n\n### Simple Hello World Function\n\n```tsx\ninterface reqPayload {\n name: string\n}\n\nconsole.info('server started')\n\nDeno.serve(async (req: Request) => {\n const { name }: reqPayload = await req.json()\n const data = {\n message: `Hello ${name} from foo!`,\n }\n\n return new Response(JSON.stringify(data), {\n headers: { 'Content-Type': 'application/json', Connection: 'keep-alive' },\n })\n})\n```\n\n### Example Function using Node built-in API\n\n```tsx\nimport { randomBytes } from 'node:crypto'\nimport { createServer } from 'node:http'\nimport process from 'node:process'\n\nconst generateRandomString = (length) => {\n const buffer = randomBytes(length)\n return buffer.toString('hex')\n}\n\nconst randomString = generateRandomString(10)\nconsole.log(randomString)\n\nconst server = createServer((req, res) => {\n const message = `Hello`\n res.end(message)\n})\n\nserver.listen(9999)\n```\n\n### Using npm packages in Functions\n\n```tsx\nimport express from 'npm:express@4.18.2'\n\nconst app = express()\n\napp.get(/(.*)/, (req, res) => {\n res.send('Welcome to Supabase')\n})\n\napp.listen(8000)\n```\n\n### Generate embeddings using built-in @Supabase.ai API\n\n```tsx\nconst model = new Supabase.ai.Session('gte-small')\n\nDeno.serve(async (req: Request) => {\n const params = new URL(req.url).searchParams\n const input = params.get('text')\n const output = await model.run(input, { mean_pool: true, normalize: true })\n return new Response(JSON.stringify(output), {\n headers: {\n 'Content-Type': 'application/json',\n Connection: 'keep-alive',\n },\n })\n})\n```\n",
|
||||
"content": "---\ndescription: Coding rules for Supabase Edge Functions\nalwaysApply: false\n---\n\n# Writing Supabase Edge Functions\n\nYou're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices:\n\n## Guidelines\n\n1. Try to use Web APIs and Deno’s core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)\n2. If you are reusing utility methods between Edge Functions, add them to `supabase/functions/_shared` and import using a relative path. Do NOT have cross dependencies between Edge Functions.\n3. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`.\n4. For external imports, always define a version. For example, `npm:@express` should be written as `npm:express@4.18.2`.\n5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from @`deno.land/x` , `esm.sh` and @`unpkg.com` . If you have a package from one of those CDNs, you can replace the CDN hostname with `npm:` specifier.\n6. You can also use Node built-in APIs. You will need to import them using `node:` specifier. For example, to import Node process: `import process from \"node:process\". Use Node APIs when you find gaps in Deno APIs.\n7. Do NOT use `import { serve } from \"https://deno.land/std@0.168.0/http/server.ts\"`. Instead use the built-in `Deno.serve`.\n8. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:\n - SUPABASE_URL\n - SUPABASE_PUBLISHABLE_OR_ANON_KEY\n - SUPABASE_SERVICE_ROLE_KEY\n - SUPABASE_DB_URL\n9. To set other environment variables (ie. secrets) users can put them in a env file and run the `supabase secrets set --env-file path/to/env-file`\n10. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with `/function-name` so they are routed correctly.\n11. File write operations are ONLY permitted on `/tmp` directory. You can use either Deno or Node File APIs.\n12. Use `EdgeRuntime.waitUntil(promise)` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context.\n\n## Example Templates\n\n### Simple Hello World Function\n\n```tsx\ninterface reqPayload {\n name: string\n}\n\nconsole.info('server started')\n\nDeno.serve(async (req: Request) => {\n const { name }: reqPayload = await req.json()\n const data = {\n message: `Hello ${name} from foo!`,\n }\n\n return new Response(JSON.stringify(data), {\n headers: { 'Content-Type': 'application/json', Connection: 'keep-alive' },\n })\n})\n```\n\n### Example Function using Node built-in API\n\n```tsx\nimport { randomBytes } from 'node:crypto'\nimport { createServer } from 'node:http'\nimport process from 'node:process'\n\nconst generateRandomString = (length) => {\n const buffer = randomBytes(length)\n return buffer.toString('hex')\n}\n\nconst randomString = generateRandomString(10)\nconsole.log(randomString)\n\nconst server = createServer((req, res) => {\n const message = `Hello`\n res.end(message)\n})\n\nserver.listen(9999)\n```\n\n### Using npm packages in Functions\n\n```tsx\nimport express from 'npm:express@4.18.2'\n\nconst app = express()\n\napp.get(/(.*)/, (req, res) => {\n res.send('Welcome to Supabase')\n})\n\napp.listen(8000)\n```\n\n### Generate embeddings using built-in @Supabase.ai API\n\n```tsx\nconst model = new Supabase.ai.Session('gte-small')\n\nDeno.serve(async (req: Request) => {\n const params = new URL(req.url).searchParams\n const input = params.get('text')\n const output = await model.run(input, { mean_pool: true, normalize: true })\n return new Response(JSON.stringify(output), {\n headers: {\n 'Content-Type': 'application/json',\n Connection: 'keep-alive',\n },\n })\n})\n```\n",
|
||||
"type": "registry:file",
|
||||
"target": "~/.cursor/rules/writing-supabase-edge-functions.mdc"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "dropzone-nuxtjs",
|
||||
"type": "registry:block",
|
||||
"title": "Dropzone (File Upload) for Nuxt and Supabase",
|
||||
"description": "Displays a control for easier uploading of files directly to Supabase Storage.",
|
||||
"dependencies": [
|
||||
"@supabase/supabase-js@latest",
|
||||
"@vueuse/core",
|
||||
"lucide-vue-next"
|
||||
],
|
||||
"registryDependencies": [
|
||||
"button"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/dropzone/nuxtjs/app/components/dropzone.vue",
|
||||
"content": "<script setup lang=\"ts\">\nimport { provide, inject } from 'vue'\nimport { cn } from \"@/lib/utils\"\n\ninterface File {\n name: string\n errors: Array<{ message: string }>\n size: number\n lastModified?: number\n type?: string\n preview?: string\n}\n\nexport interface DropzoneProps {\n className?: string\n isDragActive?: boolean\n isDragReject?: boolean\n isSuccess?: boolean\n maxFiles?: number\n maxFileSize?: number\n inputRef?: HTMLInputElement | null\n setFiles: (files: File[]) => void\n onUpload: () => Promise<void>\n loading?: boolean\n successes: string[]\n errors: Array<{ name: string; message: string }>\n files: File[]\n getRootProps: (options?: Record<string, unknown>) => Record<string, unknown>\n getInputProps: () => Record<string, unknown>\n}\n\nexport const formatBytes = (\n bytes: number,\n decimals = 2,\n size?: 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB'\n) => {\n const k = 1000\n const dm = decimals < 0 ? 0 : decimals\n const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']\n\n if (!bytes || bytes < 0) return size ? `0 ${size}` : '0 bytes'\n\n const i = size\n ? Math.max(0, sizes.indexOf(size))\n : Math.max(0, Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))))\n\n return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]\n}\n\n\nconst DropzoneContext = Symbol('DropzoneContext')\n\nexport function useDropzoneContext() {\n const ctx = inject<DropzoneProps>(DropzoneContext)\n if (!ctx) throw new Error('useDropzoneContext must be used within <Dropzone>')\n return ctx\n}\n\nconst props = defineProps<DropzoneProps>()\n\nprovide(DropzoneContext, props)\n</script>\n\n<template>\n <div\n v-bind=\"props.getRootProps({\n class: cn(\n 'border-2 border-gray-300 rounded-lg p-6 text-center bg-card transition-colors duration-300 text-foreground',\n props.className,\n props.isSuccess ? 'border-solid' : 'border-dashed',\n props.isDragActive && 'border-primary bg-primary/10',\n ((props.isDragActive && props.isDragReject) ||\n (props.errors.length > 0 && !props.isSuccess) ||\n props.files.some(f => f.errors.length !== 0))\n && 'border-destructive bg-destructive/10'\n )\n })\"\n >\n <input v-bind=\"props.getInputProps()\" />\n <slot />\n </div>\n</template>\n",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/dropzone.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/nuxtjs/app/components/dropzone-empty-state.vue",
|
||||
"content": "<script setup lang=\"ts\">\nimport { Upload } from 'lucide-vue-next'\nimport { cn } from '@/lib/utils'\nimport { useDropzoneContext, formatBytes } from './dropzone.vue'\n\nconst props = defineProps<{ className?: string }>()\n\nconst { maxFiles, maxFileSize, inputRef, isSuccess } = useDropzoneContext()\n</script>\n\n<template>\n <div v-if=\"!isSuccess\" :class=\"cn('flex flex-col items-center gap-y-2', props.className)\">\n <Upload size=\"20\" class=\"text-muted-foreground\" aria-hidden=\"true\" />\n\n <p class=\"text-sm\">\n Upload{{ maxFiles > 1 ? ` ${maxFiles}` : '' }} file{{ maxFiles !== 1 ? 's' : '' }}\n </p>\n\n <div class=\"flex flex-col items-center gap-y-1\">\n <p class=\"text-xs text-muted-foreground\">\n Drag and drop or\n <button\n type=\"button\"\n class=\"underline cursor-pointer hover:text-foreground\"\n @click=\"inputRef?.click()\"\n >\n select file{{ maxFiles !== 1 ? 's' : '' }}\n </button>\n to upload\n </p>\n\n <p v-if=\"maxFileSize !== Infinity\" class=\"text-xs text-muted-foreground\">\n Maximum file size: {{ formatBytes(maxFileSize) }}\n </p>\n </div>\n </div>\n</template>\n",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/dropzone-empty-state.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/nuxtjs/app/components/dropzone-content.vue",
|
||||
"content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils'\nimport { Button } from '@/components/ui/button'\nimport { CheckCircle, File, Loader2, X } from 'lucide-vue-next'\nimport { formatBytes, useDropzoneContext } from './dropzone.vue'\nimport { computed } from 'vue';\n\nconst props = defineProps<{ className?: string }>()\n\nconst {\n files,\n setFiles,\n onUpload,\n loading,\n successes,\n errors,\n maxFileSize,\n maxFiles,\n isSuccess,\n} = useDropzoneContext()\n\nconst exceedMaxFiles = computed(() => files.length > maxFiles)\n\nconst fileErrorMessages = computed(() => new Map(errors.map(e => [e.name, e.message])))\n\nfunction formatFileErrors(file: typeof files[number]) {\n if (!file.errors || file.errors.length === 0) return ''\n return file.errors\n .map(e =>\n e.message.startsWith('File is larger than')\n ? `File is larger than ${formatBytes(maxFileSize)} (Size: ${formatBytes(file.size)})`\n : e.message\n )\n .join(', ')\n}\n\nfunction handleRemoveFile(filename: string) {\n setFiles(files.filter(f => f.name !== filename))\n}\n</script>\n\n<template>\n <div :class=\"cn('flex flex-col', props.className)\">\n <!-- Success State -->\n <div v-if=\"isSuccess\" class=\"flex flex-row items-center gap-x-2 justify-center\" role=\"status\" aria-live=\"polite\">\n <CheckCircle size=\"16\" class=\"text-primary\" aria-hidden=\"true\"/>\n <p class=\"text-primary text-sm\">\n Successfully uploaded {{ files.length }} file{{ files.length > 1 ? 's' : '' }}\n </p>\n </div>\n\n <!-- File list -->\n <template v-else>\n <div\n v-for=\"(file, idx) in files\"\n :key=\"file?.name + '-' + file?.lastModified + '-' + file?.size || file?.name + '-' + idx\"\n class=\"flex items-center gap-x-4 border-b py-2 first:mt-4 last:mb-4\"\n >\n <div v-if=\"file.type.startsWith('image/')\"\n class=\"h-10 w-10 rounded border overflow-hidden shrink-0 bg-muted flex items-center justify-center\">\n <img :src=\"file.preview\" :alt=\"file.name\" class=\"object-cover\" />\n </div>\n\n <div v-else class=\"h-10 w-10 rounded border bg-muted flex items-center justify-center\">\n <File size=\"18\" aria-hidden=\"true\" />\n </div>\n\n <div class=\"shrink grow flex flex-col items-start truncate\">\n <p class=\"text-sm truncate max-w-full\" :title=\"file.name\">\n {{ file.name }}\n </p>\n\n <!-- ARIA live region for status messages -->\n <div aria-live=\"polite\" aria-atomic=\"true\">\n <!-- Errors -->\n <p v-if=\"file.errors.length > 0\" class=\"text-xs text-destructive\">\n {{ formatFileErrors(file) }}\n </p>\n\n <!-- Uploading -->\n <p v-else-if=\"loading\" class=\"text-xs text-muted-foreground\">Uploading file...</p>\n\n <!-- Failed -->\n <p v-else-if=\"fileErrorMessages.has(file.name)\" class=\"text-xs text-destructive\">\n Failed to upload: {{ fileErrorMessages.get(file.name) }}\n </p>\n\n <!-- Success -->\n <p v-else-if=\"successes.includes(file.name)\" class=\"text-xs text-primary\">\n Successfully uploaded file\n </p>\n\n <!-- Normal -->\n <p v-else class=\"text-xs text-muted-foreground\">\n {{ formatBytes(file.size) }}\n </p>\n </div>\n </div>\n\n <Button\n v-if=\"!loading && !successes.includes(file.name)\"\n aria-label=\"Remove file Button\"\n size=\"icon\"\n variant=\"link\"\n class=\"shrink-0 text-muted-foreground hover:text-foreground\"\n @click=\"handleRemoveFile(file.name)\"\n >\n <X />\n </Button>\n </div>\n\n <!-- Too many files -->\n <p v-if=\"exceedMaxFiles\" class=\"text-sm text-left mt-2 text-destructive\">\n You may upload only up to {{ maxFiles }} files, please remove\n {{ files.length - maxFiles }} file(s).\n </p>\n\n <!-- Upload button -->\n <div v-if=\"files.length > 0 && !exceedMaxFiles\" class=\"mt-2\">\n <Button\n variant=\"outline\"\n :disabled=\"files.some(f => f.errors.length) || loading\"\n @click=\"onUpload\"\n >\n <Loader2 v-if=\"loading\" class=\"mr-2 h-4 w-4 animate-spin\" />\n <template v-if=\"loading\">Uploading...</template>\n <template v-else>Upload files</template>\n </Button>\n </div>\n </template>\n </div>\n</template>\n",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/dropzone-content.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/nuxtjs/app/composables/useSupabaseUpload.ts",
|
||||
"content": "import { ref, computed, watch, onUnmounted } from 'vue'\nimport { useDropZone } from '@vueuse/core'\nimport { createClient } from \"@/lib/supabase/client\"\n\nconst supabase = createClient()\n\nexport interface FileWithPreview extends File {\n preview?: string\n errors: { code: string; message: string }[]\n}\n\nexport type UseSupabaseUploadOptions = {\n bucketName: string\n path?: string\n allowedMimeTypes?: string[]\n maxFileSize?: number\n maxFiles?: number\n cacheControl?: number\n upsert?: boolean\n}\n\nfunction validateFileType(file: File, allowedTypes: string[]) {\n if (!allowedTypes.length) return []\n const isValid = allowedTypes.some(t =>\n t.endsWith('/*')\n ? file.type.startsWith(t.replace('/*', ''))\n : file.type === t\n )\n return isValid\n ? []\n : [{ code: 'invalid-type', message: 'Invalid file type' }]\n}\n\nfunction validateFileSize(file: File, maxSize: number) {\n return file.size > maxSize\n ? [{ code: 'file-too-large', message: `File is larger than allowed size` }]\n : []\n}\n\nexport function useSupabaseUpload(options: UseSupabaseUploadOptions) {\n const {\n bucketName,\n path,\n allowedMimeTypes = [],\n maxFileSize = Number.POSITIVE_INFINITY,\n maxFiles = 1,\n cacheControl = 3600,\n upsert = false,\n } = options\n\n const files = ref<FileWithPreview[]>([])\n const loading = ref(false)\n const errors = ref<{ name: string; message: string }[]>([])\n const successes = ref<string[]>([])\n\n const isSuccess = computed(() => {\n if (!errors.value.length && !successes.value.length) return false\n return !errors.value.length && successes.value.length === files.value.length\n })\n\n const dropZoneRef = ref<HTMLElement | null>(null)\n\n const { isOverDropZone } = useDropZone(dropZoneRef, {\n onDrop(droppedFiles: FileWithPreview[]) {\n if (!droppedFiles) return\n\n const newFiles: FileWithPreview[] = droppedFiles.map(file => ({\n ...(file as FileWithPreview),\n preview: URL.createObjectURL(file),\n errors: [\n ...validateFileType(file, allowedMimeTypes),\n ...validateFileSize(file, maxFileSize),\n ],\n }))\n\n files.value = [...files.value, ...newFiles]\n },\n })\n\n const onUpload = async () => {\n loading.value = true\n\n try {\n const filesWithErrors = errors.value.map(e => e.name)\n\n const filesToUpload =\n filesWithErrors.length > 0\n ? files.value.filter(\n f =>\n filesWithErrors.includes(f.name) ||\n !successes.value.includes(f.name)\n )\n : files.value\n\n const responses = await Promise.all(\n filesToUpload.map(async file => {\n const { error } = await supabase.storage\n .from(bucketName)\n .upload(path ? `${path}/${file.name}` : file.name, file, {\n cacheControl: cacheControl.toString(),\n upsert,\n })\n\n return error\n ? { name: file.name, message: error.message }\n : { name: file.name, message: undefined }\n })\n )\n\n errors.value = responses.filter((r): r is { name: string; message: string } => r.message !== undefined)\n\n const successful = responses\n .filter(r => !r.message)\n .map(r => r.name)\n\n successes.value = Array.from(\n new Set([...successes.value, ...successful])\n )\n } catch (err) {\n console.error('Upload failed unexpectedly:', err)\n\n errors.value.push({\n name: 'upload',\n message: 'An unexpected error occurred during upload.',\n })\n } finally {\n loading.value = false\n }\n }\n\n\n watch(\n () => files.value.length,\n () => {\n if (!files.value.length) {\n errors.value = []\n successes.value = []\n }\n\n if (files.value.length > maxFiles) {\n errors.value.push({\n name: 'files',\n message: `You may upload up to ${maxFiles} files`,\n })\n }\n }\n )\n\n watch(\n files,\n (newFiles, oldFiles) => {\n const newPreviews = new Set(newFiles.map(f => f.preview))\n oldFiles.forEach(file => {\n if (file.preview && !newPreviews.has(file.preview)) {\n URL.revokeObjectURL(file.preview)\n }\n })\n },\n { deep: true }\n )\n\n onUnmounted(() => {\n files.value.forEach(file => {\n if (file.preview) {\n URL.revokeObjectURL(file.preview)\n }\n })\n })\n\n return {\n dropZoneRef,\n isOverDropZone,\n\n files,\n setFiles: (v: FileWithPreview[]) => (files.value = v),\n\n errors,\n setErrors: (v: { name: string; message: string }[]) => (errors.value = v),\n\n successes,\n isSuccess,\n loading,\n onUpload,\n\n maxFileSize,\n maxFiles,\n allowedMimeTypes,\n }\n}\n",
|
||||
"type": "registry:component",
|
||||
"target": "app/composables/useSupabaseUpload.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "dropzone-vue",
|
||||
"type": "registry:block",
|
||||
"title": "Dropzone (File Upload) for Vue and Supabase",
|
||||
"description": "Displays a control for easier uploading of files directly to Supabase Storage.",
|
||||
"dependencies": [
|
||||
"@supabase/supabase-js@latest",
|
||||
"@vueuse/core",
|
||||
"lucide-vue-next"
|
||||
],
|
||||
"registryDependencies": [
|
||||
"button"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/dropzone/vue/components/dropzone.vue",
|
||||
"content": "<script setup lang=\"ts\">\nimport { provide, inject } from 'vue'\nimport { cn } from \"@/lib/utils\"\n\ninterface File {\n name: string\n errors: Array<{ message: string }>\n size: number\n lastModified?: number\n type?: string\n preview?: string\n}\n\nexport interface DropzoneProps {\n className?: string\n isDragActive?: boolean\n isDragReject?: boolean\n isSuccess?: boolean\n maxFiles?: number\n maxFileSize?: number\n inputRef?: HTMLInputElement | null\n setFiles: (files: File[]) => void\n onUpload: () => Promise<void>\n loading?: boolean\n successes: string[]\n errors: Array<{ name: string; message: string }>\n files: File[]\n getRootProps: (options?: Record<string, unknown>) => Record<string, unknown>\n getInputProps: () => Record<string, unknown>\n}\n\nexport const formatBytes = (\n bytes: number,\n decimals = 2,\n size?: 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB'\n) => {\n const k = 1000\n const dm = decimals < 0 ? 0 : decimals\n const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']\n\n if (!bytes || bytes < 0) return size ? `0 ${size}` : '0 bytes'\n\n const i = size\n ? Math.max(0, sizes.indexOf(size))\n : Math.max(0, Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))))\n\n return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]\n}\n\n\nconst DropzoneContext = Symbol('DropzoneContext')\n\nexport function useDropzoneContext() {\n const ctx = inject<DropzoneProps>(DropzoneContext)\n if (!ctx) throw new Error('useDropzoneContext must be used within <Dropzone>')\n return ctx\n}\n\nconst props = defineProps<DropzoneProps>()\n\nprovide(DropzoneContext, props)\n</script>\n\n<template>\n <div\n v-bind=\"props.getRootProps({\n class: cn(\n 'border-2 border-gray-300 rounded-lg p-6 text-center bg-card transition-colors duration-300 text-foreground',\n props.className,\n props.isSuccess ? 'border-solid' : 'border-dashed',\n props.isDragActive && 'border-primary bg-primary/10',\n ((props.isDragActive && props.isDragReject) ||\n (props.errors.length > 0 && !props.isSuccess) ||\n props.files.some(f => f.errors.length !== 0))\n && 'border-destructive bg-destructive/10'\n )\n })\"\n >\n <input v-bind=\"props.getInputProps()\" />\n <slot />\n </div>\n</template>\n",
|
||||
"type": "registry:component",
|
||||
"target": "components/dropzone.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/vue/components/dropzone-empty-state.vue",
|
||||
"content": "<script setup lang=\"ts\">\nimport { Upload } from 'lucide-vue-next'\nimport { cn } from '@/lib/utils'\nimport { useDropzoneContext, formatBytes } from './dropzone.vue'\n\nconst props = defineProps<{ className?: string }>()\n\nconst { maxFiles, maxFileSize, inputRef, isSuccess } = useDropzoneContext()\n</script>\n\n<template>\n <div v-if=\"!isSuccess\" :class=\"cn('flex flex-col items-center gap-y-2', props.className)\">\n <Upload size=\"20\" class=\"text-muted-foreground\" aria-hidden=\"true\"/>\n\n <p class=\"text-sm\">\n Upload{{ maxFiles > 1 ? ` ${maxFiles}` : '' }} file{{ maxFiles !== 1 ? 's' : '' }}\n </p>\n\n <div class=\"flex flex-col items-center gap-y-1\">\n <p class=\"text-xs text-muted-foreground\">\n Drag and drop or\n <button\n type=\"button\"\n class=\"underline cursor-pointer hover:text-foreground\"\n @click=\"inputRef?.click()\"\n >\n select file{{ maxFiles !== 1 ? 's' : '' }}\n </button>\n to upload\n </p>\n\n <p v-if=\"maxFileSize !== Infinity\" class=\"text-xs text-muted-foreground\">\n Maximum file size: {{ formatBytes(maxFileSize) }}\n </p>\n </div>\n </div>\n</template>\n",
|
||||
"type": "registry:component",
|
||||
"target": "components/dropzone-empty-state.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/vue/components/dropzone-content.vue",
|
||||
"content": "<script setup lang=\"ts\">\nimport { cn } from '@/lib/utils'\nimport { Button } from '@/components/ui/button'\nimport { CheckCircle, File, Loader2, X } from 'lucide-vue-next'\nimport { formatBytes, useDropzoneContext } from './dropzone.vue'\nimport { computed } from 'vue';\n\nconst props = defineProps<{ className?: string }>()\n\nconst {\n files,\n setFiles,\n onUpload,\n loading,\n successes,\n errors,\n maxFileSize,\n maxFiles,\n isSuccess,\n} = useDropzoneContext()\n\nconst exceedMaxFiles = computed(() => files.length > maxFiles)\n\nconst fileErrorMessages = computed(() => new Map(errors.map(e => [e.name, e.message])))\n\nfunction formatFileErrors(file: typeof files[number]) {\n if (!file.errors || file.errors.length === 0) return ''\n return file.errors\n .map(e =>\n e.message.startsWith('File is larger than')\n ? `File is larger than ${formatBytes(maxFileSize)} (Size: ${formatBytes(file.size)})`\n : e.message\n )\n .join(', ')\n}\n\nfunction handleRemoveFile(filename: string) {\n setFiles(files.filter(f => f.name !== filename))\n}\n</script>\n\n<template>\n <div :class=\"cn('flex flex-col', props.className)\">\n <!-- Success State -->\n <div v-if=\"isSuccess\" class=\"flex flex-row items-center gap-x-2 justify-center\" role=\"status\" aria-live=\"polite\">\n <CheckCircle size=\"16\" class=\"text-primary\" aria-hidden=\"true\"/>\n <p class=\"text-primary text-sm\">\n Successfully uploaded {{ files.length }} file{{ files.length > 1 ? 's' : '' }}\n </p>\n </div>\n\n <!-- File list -->\n <template v-else>\n <div\n v-for=\"(file, idx) in files\"\n :key=\"file?.name + '-' + file?.lastModified + '-' + file?.size || file?.name + '-' + idx\"\n class=\"flex items-center gap-x-4 border-b py-2 first:mt-4 last:mb-4\"\n >\n <div v-if=\"file.type.startsWith('image/')\"\n class=\"h-10 w-10 rounded border overflow-hidden shrink-0 bg-muted flex items-center justify-center\">\n <img :src=\"file.preview\" :alt=\"file.name\" class=\"object-cover\" />\n </div>\n\n <div v-else class=\"h-10 w-10 rounded border bg-muted flex items-center justify-center\">\n <File size=\"18\" aria-hidden=\"true\" />\n </div>\n\n <div class=\"shrink grow flex flex-col items-start truncate\">\n <p class=\"text-sm truncate max-w-full\" :title=\"file.name\">\n {{ file.name }}\n </p>\n\n <!-- ARIA live region for status messages -->\n <div aria-live=\"polite\" aria-atomic=\"true\">\n <!-- Errors -->\n <p v-if=\"file.errors.length > 0\" class=\"text-xs text-destructive\">\n {{ formatFileErrors(file) }}\n </p>\n\n <!-- Uploading -->\n <p v-else-if=\"loading\" class=\"text-xs text-muted-foreground\">Uploading file...</p>\n\n <!-- Failed -->\n <p v-else-if=\"fileErrorMessages.has(file.name)\" class=\"text-xs text-destructive\">\n Failed to upload: {{ fileErrorMessages.get(file.name) }}\n </p>\n\n <!-- Success -->\n <p v-else-if=\"successes.includes(file.name)\" class=\"text-xs text-primary\">\n Successfully uploaded file\n </p>\n\n <!-- Normal -->\n <p v-else class=\"text-xs text-muted-foreground\">\n {{ formatBytes(file.size) }}\n </p>\n </div>\n </div>\n\n <Button\n v-if=\"!loading && !successes.includes(file.name)\"\n aria-label=\"Remove file Button\"\n size=\"icon\"\n variant=\"link\"\n class=\"shrink-0 text-muted-foreground hover:text-foreground\"\n @click=\"handleRemoveFile(file.name)\"\n >\n <X />\n </Button>\n </div>\n\n <!-- Too many files -->\n <p v-if=\"exceedMaxFiles\" class=\"text-sm text-left mt-2 text-destructive\">\n You may upload only up to {{ maxFiles }} files, please remove\n {{ files.length - maxFiles }} file(s).\n </p>\n\n <!-- Upload button -->\n <div v-if=\"files.length > 0 && !exceedMaxFiles\" class=\"mt-2\">\n <Button\n variant=\"outline\"\n :disabled=\"files.some(f => f.errors.length) || loading\"\n @click=\"onUpload\"\n >\n <Loader2 v-if=\"loading\" class=\"mr-2 h-4 w-4 animate-spin\" aria-hidden=\"true\" />\n <template v-if=\"loading\">Uploading...</template>\n <template v-else>Upload files</template>\n </Button>\n </div>\n </template>\n </div>\n</template>\n",
|
||||
"type": "registry:component",
|
||||
"target": "components/dropzone-content.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/vue/composables/useSupabaseUpload.ts",
|
||||
"content": "import { ref, computed, watch, onUnmounted } from 'vue'\nimport { useDropZone } from '@vueuse/core'\nimport { createClient } from \"@/lib/supabase/client\"\n\nconst supabase = createClient()\n\nexport interface FileWithPreview extends File {\n preview?: string\n errors: { code: string; message: string }[]\n}\n\nexport type UseSupabaseUploadOptions = {\n bucketName: string\n path?: string\n allowedMimeTypes?: string[]\n maxFileSize?: number\n maxFiles?: number\n cacheControl?: number\n upsert?: boolean\n}\n\nfunction validateFileType(file: File, allowedTypes: string[]) {\n if (!allowedTypes.length) return []\n const isValid = allowedTypes.some(t =>\n t.endsWith('/*')\n ? file.type.startsWith(t.replace('/*', ''))\n : file.type === t\n )\n return isValid\n ? []\n : [{ code: 'invalid-type', message: 'Invalid file type' }]\n}\n\nfunction validateFileSize(file: File, maxSize: number) {\n return file.size > maxSize\n ? [{ code: 'file-too-large', message: `File is larger than allowed size` }]\n : []\n}\n\nexport function useSupabaseUpload(options: UseSupabaseUploadOptions) {\n const {\n bucketName,\n path,\n allowedMimeTypes = [],\n maxFileSize = Number.POSITIVE_INFINITY,\n maxFiles = 1,\n cacheControl = 3600,\n upsert = false,\n } = options\n\n const files = ref<FileWithPreview[]>([])\n const loading = ref(false)\n const errors = ref<{ name: string; message: string }[]>([])\n const successes = ref<string[]>([])\n\n const isSuccess = computed(() => {\n if (!errors.value.length && !successes.value.length) return false\n return !errors.value.length && successes.value.length === files.value.length\n })\n\n const dropZoneRef = ref<HTMLElement | null>(null)\n\n const { isOverDropZone } = useDropZone(dropZoneRef, {\n onDrop(droppedFiles: FileWithPreview[]) {\n if (!droppedFiles) return\n\n const newFiles: FileWithPreview[] = droppedFiles.map(file => ({\n ...(file as FileWithPreview),\n preview: URL.createObjectURL(file),\n errors: [\n ...validateFileType(file, allowedMimeTypes),\n ...validateFileSize(file, maxFileSize),\n ],\n }))\n\n files.value = [...files.value, ...newFiles]\n },\n })\n\n const onUpload = async () => {\n loading.value = true\n\n try {\n const filesWithErrors = errors.value.map(e => e.name)\n\n const filesToUpload =\n filesWithErrors.length > 0\n ? files.value.filter(\n f =>\n filesWithErrors.includes(f.name) ||\n !successes.value.includes(f.name)\n )\n : files.value\n\n const responses = await Promise.all(\n filesToUpload.map(async file => {\n const { error } = await supabase.storage\n .from(bucketName)\n .upload(path ? `${path}/${file.name}` : file.name, file, {\n cacheControl: cacheControl.toString(),\n upsert,\n })\n\n return error\n ? { name: file.name, message: error.message }\n : { name: file.name, message: undefined }\n })\n )\n\n errors.value = responses.filter((r): r is { name: string; message: string } => r.message !== undefined)\n\n const successful = responses\n .filter(r => !r.message)\n .map(r => r.name)\n\n successes.value = Array.from(\n new Set([...successes.value, ...successful])\n )\n } catch (err) {\n console.error('Upload failed unexpectedly:', err)\n\n errors.value.push({\n name: 'upload',\n message: 'An unexpected error occurred during upload.',\n })\n } finally {\n loading.value = false\n }\n }\n\n\n watch(\n () => files.value.length,\n () => {\n if (!files.value.length) {\n errors.value = []\n successes.value = []\n }\n\n if (files.value.length > maxFiles) {\n errors.value.push({\n name: 'files',\n message: `You may upload up to ${maxFiles} files`,\n })\n }\n }\n )\n\n watch(\n files,\n (newFiles, oldFiles) => {\n const newPreviews = new Set(newFiles.map(f => f.preview))\n oldFiles.forEach(file => {\n if (file.preview && !newPreviews.has(file.preview)) {\n URL.revokeObjectURL(file.preview)\n }\n })\n },\n { deep: true }\n )\n\n onUnmounted(() => {\n files.value.forEach(file => {\n if (file.preview) {\n URL.revokeObjectURL(file.preview)\n }\n })\n })\n\n return {\n dropZoneRef,\n isOverDropZone,\n\n files,\n setFiles: (v: FileWithPreview[]) => (files.value = v),\n\n errors,\n setErrors: (v: { name: string; message: string }[]) => (errors.value = v),\n\n successes,\n isSuccess,\n loading,\n onUpload,\n\n maxFileSize,\n maxFiles,\n allowedMimeTypes,\n }\n}\n",
|
||||
"type": "registry:component",
|
||||
"target": "composables/useSupabaseUpload.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/password-based-auth-react-router/app/routes/sign-up.tsx",
|
||||
"content": "import { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { type ActionFunctionArgs, Link, redirect, useFetcher, useSearchParams } from 'react-router'\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { supabase } = createClient(request)\n\n const url = new URL(request.url)\n const origin = url.origin\n\n const formData = await request.formData()\n\n const email = formData.get('email') as string\n const password = formData.get('password') as string\n const repeatPassword = formData.get('repeat-password') as string\n\n if (!password) {\n return {\n error: 'Password is required',\n }\n }\n\n if (password !== repeatPassword) {\n return { error: 'Passwords do not match' }\n }\n\n const { error } = await supabase.auth.signUp({\n email,\n password,\n options: {\n emailRedirectTo: `${origin}/protected`,\n },\n })\n\n if (error) {\n return { error: error.message }\n }\n\n return redirect('/sign-up?success')\n}\n\nexport default function SignUp() {\n const fetcher = useFetcher<typeof action>()\n let [searchParams] = useSearchParams()\n\n const success = !!searchParams.has('success')\n const error = fetcher.data?.error\n const loading = fetcher.state === 'submitting'\n\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <div className=\"flex flex-col gap-6\">\n {success ? (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Thank you for signing up!</CardTitle>\n <CardDescription>Check your email to confirm</CardDescription>\n </CardHeader>\n <CardContent>\n <p className=\"text-sm text-muted-foreground\">\n You've successfully signed up. Please check your email to confirm your account\n before signing in.\n </p>\n </CardContent>\n </Card>\n ) : (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Sign up</CardTitle>\n <CardDescription>Create a new account</CardDescription>\n </CardHeader>\n <CardContent>\n <fetcher.Form method=\"post\">\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n name=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"password\">Password</Label>\n </div>\n <Input id=\"password\" name=\"password\" type=\"password\" required />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"repeat-password\">Repeat Password</Label>\n </div>\n <Input id=\"repeat-password\" name=\"repeat-password\" type=\"password\" required />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={loading}>\n {loading ? 'Creating an account...' : 'Sign up'}\n </Button>\n </div>\n <div className=\"mt-4 text-center text-sm\">\n Already have an account?{' '}\n <Link to=\"/login\" className=\"underline underline-offset-4\">\n Login\n </Link>\n </div>\n </fetcher.Form>\n </CardContent>\n </Card>\n )}\n </div>\n </div>\n </div>\n )\n}\n",
|
||||
"content": "import { createClient } from '@/registry/default/clients/react-router/lib/supabase/server'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { type ActionFunctionArgs, Link, redirect, useFetcher, useSearchParams } from 'react-router'\n\nexport const action = async ({ request }: ActionFunctionArgs) => {\n const { supabase } = createClient(request)\n\n const url = new URL(request.url)\n const origin = url.origin\n\n const formData = await request.formData()\n\n const email = formData.get('email') as string\n const password = formData.get('password') as string\n const repeatPassword = formData.get('repeat-password') as string\n\n if (!password) {\n return {\n error: 'Password is required',\n }\n }\n\n if (password !== repeatPassword) {\n return { error: 'Passwords do not match' }\n }\n\n const { error } = await supabase.auth.signUp({\n email,\n password,\n options: {\n emailRedirectTo: `${origin}/protected`,\n },\n })\n\n if (error) {\n return { error: error.message }\n }\n\n return redirect('/sign-up?success')\n}\n\nexport default function SignUp() {\n const fetcher = useFetcher<typeof action>()\n let [searchParams] = useSearchParams()\n\n const success = !!searchParams.has('success')\n const error = fetcher.data?.error\n const loading = fetcher.state === 'submitting'\n\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <div className=\"flex flex-col gap-6\">\n {success ? (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Thank you for signing up!</CardTitle>\n <CardDescription>Check your email to confirm</CardDescription>\n </CardHeader>\n <CardContent>\n <p className=\"text-sm text-muted-foreground\">\n You've successfully signed up. Please check your email to confirm your\n account before signing in.\n </p>\n </CardContent>\n </Card>\n ) : (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Sign up</CardTitle>\n <CardDescription>Create a new account</CardDescription>\n </CardHeader>\n <CardContent>\n <fetcher.Form method=\"post\">\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n name=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"password\">Password</Label>\n </div>\n <Input id=\"password\" name=\"password\" type=\"password\" required />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"repeat-password\">Repeat Password</Label>\n </div>\n <Input id=\"repeat-password\" name=\"repeat-password\" type=\"password\" required />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={loading}>\n {loading ? 'Creating an account...' : 'Sign up'}\n </Button>\n </div>\n <div className=\"mt-4 text-center text-sm\">\n Already have an account?{' '}\n <Link to=\"/login\" className=\"underline underline-offset-4\">\n Login\n </Link>\n </div>\n </fetcher.Form>\n </CardContent>\n </Card>\n )}\n </div>\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "app/routes/sign-up.tsx"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/password-based-auth-react/components/sign-up-form.tsx",
|
||||
"content": "import { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\nexport function SignUpForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [repeatPassword, setRepeatPassword] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [success, setSuccess] = useState(false)\n\n const handleSignUp = async (e: React.FormEvent) => {\n const supabase = createClient()\n e.preventDefault()\n setError(null)\n\n if (password !== repeatPassword) {\n setError('Passwords do not match')\n return\n }\n setIsLoading(true)\n\n try {\n const { error } = await supabase.auth.signUp({\n email,\n password,\n })\n if (error) throw error\n setSuccess(true)\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n {success ? (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Thank you for signing up!</CardTitle>\n <CardDescription>Check your email to confirm</CardDescription>\n </CardHeader>\n <CardContent>\n <p className=\"text-sm text-muted-foreground\">\n You've successfully signed up. Please check your email to confirm your account before\n signing in.\n </p>\n </CardContent>\n </Card>\n ) : (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Sign up</CardTitle>\n <CardDescription>Create a new account</CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleSignUp}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"password\">Password</Label>\n </div>\n <Input\n id=\"password\"\n type=\"password\"\n required\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"repeat-password\">Repeat Password</Label>\n </div>\n <Input\n id=\"repeat-password\"\n type=\"password\"\n required\n value={repeatPassword}\n onChange={(e) => setRepeatPassword(e.target.value)}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading ? 'Creating an account...' : 'Sign up'}\n </Button>\n </div>\n <div className=\"mt-4 text-center text-sm\">\n Already have an account?{' '}\n <a href=\"/login\" className=\"underline underline-offset-4\">\n Login\n </a>\n </div>\n </form>\n </CardContent>\n </Card>\n )}\n </div>\n )\n}\n",
|
||||
"content": "import { cn } from '@/lib/utils'\nimport { createClient } from '@/registry/default/clients/react/lib/supabase/client'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useState } from 'react'\n\nexport function SignUpForm({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {\n const [email, setEmail] = useState('')\n const [password, setPassword] = useState('')\n const [repeatPassword, setRepeatPassword] = useState('')\n const [error, setError] = useState<string | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [success, setSuccess] = useState(false)\n\n const handleSignUp = async (e: React.FormEvent) => {\n const supabase = createClient()\n e.preventDefault()\n setError(null)\n\n if (password !== repeatPassword) {\n setError('Passwords do not match')\n return\n }\n setIsLoading(true)\n\n try {\n const { error } = await supabase.auth.signUp({\n email,\n password,\n })\n if (error) throw error\n setSuccess(true)\n } catch (error: unknown) {\n setError(error instanceof Error ? error.message : 'An error occurred')\n } finally {\n setIsLoading(false)\n }\n }\n\n return (\n <div className={cn('flex flex-col gap-6', className)} {...props}>\n {success ? (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Thank you for signing up!</CardTitle>\n <CardDescription>Check your email to confirm</CardDescription>\n </CardHeader>\n <CardContent>\n <p className=\"text-sm text-muted-foreground\">\n You've successfully signed up. Please check your email to confirm your account\n before signing in.\n </p>\n </CardContent>\n </Card>\n ) : (\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Sign up</CardTitle>\n <CardDescription>Create a new account</CardDescription>\n </CardHeader>\n <CardContent>\n <form onSubmit={handleSignUp}>\n <div className=\"flex flex-col gap-6\">\n <div className=\"grid gap-2\">\n <Label htmlFor=\"email\">Email</Label>\n <Input\n id=\"email\"\n type=\"email\"\n placeholder=\"m@example.com\"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"password\">Password</Label>\n </div>\n <Input\n id=\"password\"\n type=\"password\"\n required\n value={password}\n onChange={(e) => setPassword(e.target.value)}\n />\n </div>\n <div className=\"grid gap-2\">\n <div className=\"flex items-center\">\n <Label htmlFor=\"repeat-password\">Repeat Password</Label>\n </div>\n <Input\n id=\"repeat-password\"\n type=\"password\"\n required\n value={repeatPassword}\n onChange={(e) => setRepeatPassword(e.target.value)}\n />\n </div>\n {error && <p className=\"text-sm text-red-500\">{error}</p>}\n <Button type=\"submit\" className=\"w-full\" disabled={isLoading}>\n {isLoading ? 'Creating an account...' : 'Sign up'}\n </Button>\n </div>\n <div className=\"mt-4 text-center text-sm\">\n Already have an account?{' '}\n <a href=\"/login\" className=\"underline underline-offset-4\">\n Login\n </a>\n </div>\n </form>\n </CardContent>\n </Card>\n )}\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
{
|
||||
"path": "registry/default/blocks/password-based-auth-tanstack/routes/sign-up-success.tsx",
|
||||
"content": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { createFileRoute } from '@tanstack/react-router'\n\nexport const Route = createFileRoute('/sign-up-success')({\n component: SignUpSuccess,\n})\n\nfunction SignUpSuccess() {\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <div className=\"flex flex-col gap-6\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Thank you for signing up!</CardTitle>\n <CardDescription>Check your email to confirm</CardDescription>\n </CardHeader>\n <CardContent>\n <p className=\"text-sm text-muted-foreground\">\n You've successfully signed up. Please check your email to confirm your account\n before signing in.\n </p>\n </CardContent>\n </Card>\n </div>\n </div>\n </div>\n )\n}\n",
|
||||
"content": "import {\n Card,\n CardContent,\n CardDescription,\n CardHeader,\n CardTitle,\n} from '@/registry/default/components/ui/card'\nimport { createFileRoute } from '@tanstack/react-router'\n\nexport const Route = createFileRoute('/sign-up-success')({\n component: SignUpSuccess,\n})\n\nfunction SignUpSuccess() {\n return (\n <div className=\"flex min-h-svh w-full items-center justify-center p-6 md:p-10\">\n <div className=\"w-full max-w-sm\">\n <div className=\"flex flex-col gap-6\">\n <Card>\n <CardHeader>\n <CardTitle className=\"text-2xl\">Thank you for signing up!</CardTitle>\n <CardDescription>Check your email to confirm</CardDescription>\n </CardHeader>\n <CardContent>\n <p className=\"text-sm text-muted-foreground\">\n You've successfully signed up. Please check your email to confirm your account\n before signing in.\n </p>\n </CardContent>\n </Card>\n </div>\n </div>\n </div>\n )\n}\n",
|
||||
"type": "registry:file",
|
||||
"target": "routes/sign-up-success.tsx"
|
||||
},
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
},
|
||||
{
|
||||
"path": "registry/default/platform/platform-kit-nextjs/app/api/supabase-proxy/[...path]/route.ts",
|
||||
"content": "import { NextResponse } from 'next/server'\n\nasync function forwardToSupabaseAPI(request: Request, method: string, params: { path: string[] }) {\n if (!process.env.SUPABASE_MANAGEMENT_API_TOKEN) {\n console.error('Supabase Management API token is not configured.')\n return NextResponse.json({ message: 'Server configuration error.' }, { status: 500 })\n }\n\n const { path } = params\n const apiPath = path.join('/')\n\n const url = new URL(request.url)\n url.protocol = 'https'\n url.hostname = 'api.supabase.com'\n url.port = '443'\n url.pathname = apiPath\n\n const projectRef = path[2]\n\n // Implement your permission check here (e.g. check if the user is a member of the project)\n // In this example, everyone can access all projects\n const userHasPermissionForProject = Boolean(projectRef)\n\n if (!userHasPermissionForProject) {\n return NextResponse.json(\n { message: 'You do not have permission to access this project.' },\n { status: 403 }\n )\n }\n\n try {\n const forwardHeaders: HeadersInit = {\n Authorization: `Bearer ${process.env.SUPABASE_MANAGEMENT_API_TOKEN}`,\n }\n\n // Copy relevant headers from the original request\n const contentType = request.headers.get('content-type')\n if (contentType) {\n forwardHeaders['Content-Type'] = contentType\n }\n\n const fetchOptions: RequestInit = {\n method,\n headers: forwardHeaders,\n }\n\n // Include body for methods that support it\n if (method !== 'GET' && method !== 'HEAD') {\n try {\n const body = await request.text()\n if (body) {\n fetchOptions.body = body\n }\n } catch (error) {\n // Handle cases where body is not readable\n console.warn('Could not read request body:', error)\n }\n }\n\n const response = await fetch(url, fetchOptions)\n\n // Get response body\n const responseText = await response.text()\n let responseData\n\n try {\n responseData = responseText ? JSON.parse(responseText) : null\n } catch {\n responseData = responseText\n }\n\n // Return the response with the same status\n return NextResponse.json(responseData, { status: response.status })\n } catch (error: any) {\n console.error('Supabase API proxy error:', error)\n const errorMessage = error.message || 'An unexpected error occurred.'\n return NextResponse.json({ message: errorMessage }, { status: 500 })\n }\n}\n\nexport async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'GET', resolvedParams)\n}\n\nexport async function HEAD(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'HEAD', resolvedParams)\n}\n\nexport async function POST(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'POST', resolvedParams)\n}\n\nexport async function PUT(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PUT', resolvedParams)\n}\n\nexport async function DELETE(\n request: Request,\n { params }: { params: Promise<{ path: string[] }> }\n) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'DELETE', resolvedParams)\n}\n\nexport async function PATCH(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PATCH', resolvedParams)\n}\n",
|
||||
"content": "import { NextResponse } from 'next/server'\n\nasync function forwardToSupabaseAPI(request: Request, method: string, params: { path: string[] }) {\n // eslint-disable-next-line turbo/no-undeclared-env-vars\n if (!process.env.SUPABASE_MANAGEMENT_API_TOKEN) {\n console.error('Supabase Management API token is not configured.')\n return NextResponse.json({ message: 'Server configuration error.' }, { status: 500 })\n }\n\n const { path } = params\n const apiPath = path.join('/')\n\n const url = new URL(request.url)\n url.protocol = 'https'\n url.hostname = 'api.supabase.com'\n url.port = '443'\n url.pathname = apiPath\n\n const projectRef = path[2]\n\n // Implement your permission check here (e.g. check if the user is a member of the project)\n // In this example, everyone can access all projects\n const userHasPermissionForProject = Boolean(projectRef)\n\n if (!userHasPermissionForProject) {\n return NextResponse.json(\n { message: 'You do not have permission to access this project.' },\n { status: 403 }\n )\n }\n\n try {\n const forwardHeaders: HeadersInit = {\n // eslint-disable-next-line turbo/no-undeclared-env-vars\n Authorization: `Bearer ${process.env.SUPABASE_MANAGEMENT_API_TOKEN}`,\n }\n\n // Copy relevant headers from the original request\n const contentType = request.headers.get('content-type')\n if (contentType) {\n forwardHeaders['Content-Type'] = contentType\n }\n\n const fetchOptions: RequestInit = {\n method,\n headers: forwardHeaders,\n }\n\n // Include body for methods that support it\n if (method !== 'GET' && method !== 'HEAD') {\n try {\n const body = await request.text()\n if (body) {\n fetchOptions.body = body\n }\n } catch (error) {\n // Handle cases where body is not readable\n console.warn('Could not read request body:', error)\n }\n }\n\n const response = await fetch(url, fetchOptions)\n\n // Get response body\n const responseText = await response.text()\n let responseData\n\n try {\n responseData = responseText ? JSON.parse(responseText) : null\n } catch {\n responseData = responseText\n }\n\n // Return the response with the same status\n return NextResponse.json(responseData, { status: response.status })\n } catch (error: any) {\n console.error('Supabase API proxy error:', error)\n const errorMessage = error.message || 'An unexpected error occurred.'\n return NextResponse.json({ message: errorMessage }, { status: 500 })\n }\n}\n\nexport async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'GET', resolvedParams)\n}\n\nexport async function HEAD(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'HEAD', resolvedParams)\n}\n\nexport async function POST(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'POST', resolvedParams)\n}\n\nexport async function PUT(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PUT', resolvedParams)\n}\n\nexport async function DELETE(\n request: Request,\n { params }: { params: Promise<{ path: string[] }> }\n) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'DELETE', resolvedParams)\n}\n\nexport async function PATCH(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PATCH', resolvedParams)\n}\n",
|
||||
"type": "registry:page",
|
||||
"target": "app/api/supabase-proxy/[...path]/route.ts"
|
||||
},
|
||||
@@ -106,7 +106,7 @@
|
||||
},
|
||||
{
|
||||
"path": "registry/default/platform/platform-kit-nextjs/components/supabase-manager/suggestions.tsx",
|
||||
"content": "'use client'\n\nimport { useGetSuggestions } from '@/registry/default/platform/platform-kit-nextjs/hooks/use-suggestions'\nimport { Alert, AlertDescription, AlertTitle } from '@/registry/default/components/ui/alert'\nimport { Terminal } from 'lucide-react'\nimport { Badge } from '@/registry/default/components/ui/badge'\nimport { useMemo } from 'react'\n\nimport ReactMarkdown from 'react-markdown'\nimport { Skeleton } from '@/registry/default/components/ui/skeleton'\n\nexport function SuggestionsManager({ projectRef }: { projectRef: string }) {\n const { data: suggestions, isLoading, error } = useGetSuggestions(projectRef)\n\n const sortedSuggestions = useMemo(() => {\n if (!suggestions) return []\n const levelOrder = { ERROR: 1, WARN: 2, INFO: 3 }\n return [...suggestions].sort((a: any, b: any) => {\n const levelA = levelOrder[a.level as keyof typeof levelOrder] || 99\n const levelB = levelOrder[b.level as keyof typeof levelOrder] || 99\n return levelA - levelB\n })\n }, [suggestions])\n\n const getBadgeVariant = (level: 'ERROR' | 'WARN' | 'INFO') => {\n switch (level) {\n case 'ERROR':\n return 'destructive'\n case 'WARN':\n return 'secondary'\n default:\n return 'outline'\n }\n }\n\n return (\n <div className=\"p-6 pt-4 lg:p-12 lg:pt-12 max-w-3xl mx-auto\">\n <h2 className=\"text-base lg:text-xl font-semibold mb-1\">Suggestions</h2>\n <p className=\"text-muted-foreground mb-4 lg:mb-8 text-sm lg:text-base\">\n Improve your project's security and performance.\n </p>\n {isLoading && (\n <div className=\"space-y-2\">\n <Skeleton className=\"h-6 w-full\" />\n <Skeleton className=\"h-6 w-full\" />\n <Skeleton className=\"h-6 w-full\" />\n <Skeleton className=\"h-6 w-full\" />\n </div>\n )}\n {error && (\n <Alert variant=\"destructive\" className=\"mt-8\">\n <Terminal className=\"h-4 w-4\" />\n <AlertTitle>Error fetching suggestions</AlertTitle>\n <AlertDescription>\n {(error as any)?.message || 'An unexpected error occurred. Please try again.'}\n </AlertDescription>\n </Alert>\n )}\n {suggestions && (\n <div>\n {sortedSuggestions.length > 0 ? (\n <div>\n {sortedSuggestions.map((suggestion: any) => (\n <div\n key={suggestion.cache_key}\n className=\"py-4 border-b last:border-b-0 group relative\"\n >\n <div className=\"flex-1\">\n <div className=\"flex justify-start items-start gap-4\">\n <h4 className=\"font-semibold text-sm\">{suggestion.title}</h4>\n <div className=\"flex items-center gap-1\">\n <Badge variant={getBadgeVariant(suggestion.level)} className=\"shrink-0\">\n {suggestion.level}\n </Badge>\n {suggestion.type && (\n <Badge\n variant={suggestion.type === 'security' ? 'destructive' : 'secondary'}\n className=\"shrink-0\"\n >\n {suggestion.type.charAt(0).toUpperCase() + suggestion.type.slice(1)}\n </Badge>\n )}\n </div>\n </div>\n <div className=\"text-sm text-muted-foreground mt-2 prose prose-sm max-w-none\">\n <ReactMarkdown\n components={{\n code({ inline, children, ...props }: any) {\n return inline ? (\n <code className=\"bg-muted px-1 rounded\" {...props}>\n {children}\n </code>\n ) : (\n <pre className=\"bg-muted p-2 rounded overflow-x-auto\" {...props}>\n <code>{children}</code>\n </pre>\n )\n },\n }}\n >\n {suggestion.detail}\n </ReactMarkdown>\n </div>\n </div>\n </div>\n ))}\n </div>\n ) : (\n <Alert>\n <Terminal className=\"h-4 w-4\" />\n <AlertTitle>No suggestions found</AlertTitle>\n <AlertDescription>\n Your project looks good! No suggestions at this time.\n </AlertDescription>\n </Alert>\n )}\n </div>\n )}\n </div>\n )\n}\n",
|
||||
"content": "'use client'\n\nimport { useGetSuggestions } from '@/registry/default/platform/platform-kit-nextjs/hooks/use-suggestions'\nimport { Alert, AlertDescription, AlertTitle } from '@/registry/default/components/ui/alert'\nimport { Terminal } from 'lucide-react'\nimport { Badge } from '@/registry/default/components/ui/badge'\nimport { useMemo } from 'react'\n\nimport ReactMarkdown from 'react-markdown'\nimport { Skeleton } from '@/registry/default/components/ui/skeleton'\n\nexport function SuggestionsManager({ projectRef }: { projectRef: string }) {\n const { data: suggestions, isLoading, error } = useGetSuggestions(projectRef)\n\n const sortedSuggestions = useMemo(() => {\n if (!suggestions) return []\n const levelOrder = { ERROR: 1, WARN: 2, INFO: 3 }\n return [...suggestions].sort((a: any, b: any) => {\n const levelA = levelOrder[a.level as keyof typeof levelOrder] || 99\n const levelB = levelOrder[b.level as keyof typeof levelOrder] || 99\n return levelA - levelB\n })\n }, [suggestions])\n\n const getBadgeVariant = (level: 'ERROR' | 'WARN' | 'INFO') => {\n switch (level) {\n case 'ERROR':\n return 'destructive'\n case 'WARN':\n return 'secondary'\n default:\n return 'outline'\n }\n }\n\n return (\n <div className=\"p-6 pt-4 lg:p-12 lg:pt-12 max-w-3xl mx-auto\">\n <h2 className=\"text-base lg:text-xl font-semibold mb-1\">Suggestions</h2>\n <p className=\"text-muted-foreground mb-4 lg:mb-8 text-sm lg:text-base\">\n Improve your project's security and performance.\n </p>\n {isLoading && (\n <div className=\"space-y-2\">\n <Skeleton className=\"h-6 w-full\" />\n <Skeleton className=\"h-6 w-full\" />\n <Skeleton className=\"h-6 w-full\" />\n <Skeleton className=\"h-6 w-full\" />\n </div>\n )}\n {error && (\n <Alert variant=\"destructive\" className=\"mt-8\">\n <Terminal className=\"h-4 w-4\" />\n <AlertTitle>Error fetching suggestions</AlertTitle>\n <AlertDescription>\n {(error as any)?.message || 'An unexpected error occurred. Please try again.'}\n </AlertDescription>\n </Alert>\n )}\n {suggestions && (\n <div>\n {sortedSuggestions.length > 0 ? (\n <div>\n {sortedSuggestions.map((suggestion: any) => (\n <div\n key={suggestion.cache_key}\n className=\"py-4 border-b last:border-b-0 group relative\"\n >\n <div className=\"flex-1\">\n <div className=\"flex justify-start items-start gap-4\">\n <h4 className=\"font-semibold text-sm\">{suggestion.title}</h4>\n <div className=\"flex items-center gap-1\">\n <Badge variant={getBadgeVariant(suggestion.level)} className=\"shrink-0\">\n {suggestion.level}\n </Badge>\n {suggestion.type && (\n <Badge\n variant={suggestion.type === 'security' ? 'destructive' : 'secondary'}\n className=\"shrink-0\"\n >\n {suggestion.type.charAt(0).toUpperCase() + suggestion.type.slice(1)}\n </Badge>\n )}\n </div>\n </div>\n <div className=\"text-sm text-muted-foreground mt-2 prose prose-sm max-w-none\">\n <ReactMarkdown\n components={{\n code({ inline, children, ...props }: any) {\n return inline ? (\n <code className=\"bg-muted px-1 rounded\" {...props}>\n {children}\n </code>\n ) : (\n <pre className=\"bg-muted p-2 rounded overflow-x-auto\" {...props}>\n <code>{children}</code>\n </pre>\n )\n },\n }}\n >\n {suggestion.detail}\n </ReactMarkdown>\n </div>\n </div>\n </div>\n ))}\n </div>\n ) : (\n <Alert>\n <Terminal className=\"h-4 w-4\" />\n <AlertTitle>No suggestions found</AlertTitle>\n <AlertDescription>\n Your project looks good! No suggestions at this time.\n </AlertDescription>\n </Alert>\n )}\n </div>\n )}\n </div>\n )\n}\n",
|
||||
"type": "registry:component"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "realtime-cursor-nuxtjs",
|
||||
"type": "registry:component",
|
||||
"title": "Realtime Cursor for Nuxt and Supabase",
|
||||
"description": "Component which renders realtime cursors from other users in a room.",
|
||||
"dependencies": [
|
||||
"@supabase/supabase-js@latest",
|
||||
"@vueuse/core",
|
||||
"lucide-vue-next"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/nuxtjs/app/components/cursor.vue",
|
||||
"content": "<script setup lang=\"ts\">\nimport { MousePointer2 } from 'lucide-vue-next'\nimport { cn } from '@/lib/utils'\nimport type { CSSProperties } from 'vue'\n\ndefineProps<{\n className?: string\n style?: CSSProperties\n color: string\n name: string\n}>()\n</script>\n\n<template>\n <div\n :class=\"cn('pointer-events-none', className)\"\n :style=\"style\"\n >\n <MousePointer2\n :color=\"color\"\n :fill=\"color\"\n :size=\"30\"\n />\n\n <div\n class=\"mt-1 px-2 py-1 rounded text-xs font-bold text-white text-center\"\n :style=\"{ backgroundColor: color }\"\n >\n {{ name }}\n </div>\n </div>\n</template>\n",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/cursor.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/nuxtjs/app/components/realtime-cursors.vue",
|
||||
"content": "<script setup lang=\"ts\">\nimport { Cursor } from '@/components/cursor'\nimport { useRealtimeCursors } from '@/composables/useRealtimeCursors'\n\nconst THROTTLE_MS = 50\n\nconst props = defineProps<{\n roomName: string\n username: string\n}>()\n\nconst { cursors } = useRealtimeCursors({\n roomName: props.roomName,\n username: props.username,\n throttleMs: THROTTLE_MS,\n})\n</script>\n\n<template>\n <div>\n <Cursor\n v-for=\"(cursor, id) in cursors\"\n :key=\"id\"\n className=\"fixed transition-transform ease-in-out z-50\"\n :style=\"{\n transitionDuration: '20ms',\n top: '0px',\n left: '0px',\n transform: `translate(${cursor.position.x}px, ${cursor.position.y}px)`,\n }\"\n :color=\"cursor.color\"\n :name=\"cursor.user.name\"\n />\n </div>\n</template>\n",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/realtime-cursors.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/nuxtjs/app/composables/useRealtimeCursors.ts",
|
||||
"content": "import { ref, reactive, onMounted, onUnmounted } from 'vue'\nimport { createClient } from '@/lib/supabase/client'\nimport { REALTIME_SUBSCRIBE_STATES, type RealtimeChannel } from '@supabase/supabase-js'\n\n/**\n * Throttle a callback to a certain delay.\n * It will only call the callback if the delay has passed,\n * using the arguments from the last call.\n */\nfunction useThrottleCallback<Params extends unknown[]>(\n callback: (...args: Params) => void,\n delay: number\n) {\n let lastCall = 0\n let timeout: ReturnType<typeof setTimeout> | null = null\n\n const run = (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall)\n\n if (remainingTime <= 0) {\n if (timeout) {\n clearTimeout(timeout)\n timeout = null\n }\n lastCall = now\n callback(...args)\n } else if (!timeout) {\n timeout = setTimeout(() => {\n lastCall = Date.now()\n timeout = null\n callback(...args)\n }, remainingTime)\n }\n }\n\n const cancel = () => {\n if (timeout) {\n clearTimeout(timeout)\n timeout = null\n }\n }\n\n return { run, cancel }\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () =>\n `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () =>\n Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\nexport type CursorEventPayload = {\n position: { x: number; y: number }\n user: { id: number; name: string }\n color: string\n timestamp: number\n}\n\nexport function useRealtimeCursors({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) {\n const color = generateRandomColor()\n const userId = generateRandomNumber()\n\n const cursors = reactive<Record<string, CursorEventPayload>>({})\n const cursorPayload = ref<CursorEventPayload | null>(null)\n const channelRef = ref<RealtimeChannel | null>(null)\n\n const sendCursor = (event: MouseEvent) => {\n const payload: CursorEventPayload = {\n position: {\n x: event.clientX,\n y: event.clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color,\n timestamp: Date.now(),\n }\n\n cursorPayload.value = payload\n\n channelRef.value?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload,\n })\n }\n\n const { run: handleMouseMove, cancel: cancelThrottle } =\n useThrottleCallback(sendCursor, throttleMs)\n\n onMounted(() => {\n const channel = supabase.channel(roomName)\n\n channel\n .on('system', {}, (payload: CursorEventPayload) => {\n console.error('Realtime system error:', payload)\n\n // Defensive cleanup\n Object.keys(cursors).forEach((k) => delete cursors[k])\n channelRef.value = null\n })\n .on('presence', { event: 'leave' }, ({ leftPresences }) => {\n leftPresences.forEach(({ key }) => {\n delete cursors[key]\n })\n })\n .on('presence', { event: 'join' }, () => {\n if (!cursorPayload.value) return\n\n channelRef.value?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: cursorPayload.value,\n })\n })\n .on('broadcast', { event: EVENT_NAME }, ({ payload }: { payload: CursorEventPayload }) => {\n if (payload.user.id === userId) return\n\n cursors[payload.user.id] = payload\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n try {\n await channel.track({ key: userId })\n channelRef.value = channel\n } catch (err) {\n console.error('Failed to track presence for current user:', err)\n channelRef.value = null\n }\n } else {\n Object.keys(cursors).forEach((k) => delete cursors[k])\n channelRef.value = null\n }\n })\n\n window.addEventListener('mousemove', handleMouseMove)\n })\n\n onUnmounted(() => {\n window.removeEventListener('mousemove', handleMouseMove)\n\n cancelThrottle()\n\n if (channelRef.value) {\n channelRef.value.unsubscribe()\n channelRef.value = null\n }\n\n Object.keys(cursors).forEach((k) => delete cursors[k])\n })\n\n return { cursors }\n}\n",
|
||||
"type": "registry:component",
|
||||
"target": "app/composables/useRealtimeCursors.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "realtime-cursor-vue",
|
||||
"type": "registry:component",
|
||||
"title": "Realtime Cursor for Vue and Supabase",
|
||||
"description": "Component which renders realtime cursors from other users in a room.",
|
||||
"dependencies": [
|
||||
"@supabase/supabase-js@latest",
|
||||
"@vueuse/core",
|
||||
"lucide-vue-next"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/vue/components/cursor.vue",
|
||||
"content": "<script setup lang=\"ts\">\nimport { MousePointer2 } from 'lucide-vue-next'\nimport { cn } from '@/lib/utils'\nimport type { CSSProperties } from 'vue'\n\ndefineProps<{\n className?: string\n style?: CSSProperties\n color: string\n name: string\n}>()\n</script>\n\n<template>\n <div\n :class=\"cn('pointer-events-none', className)\"\n :style=\"style\"\n >\n <MousePointer2\n :color=\"color\"\n :fill=\"color\"\n :size=\"30\"\n />\n\n <div\n class=\"mt-1 px-2 py-1 rounded text-xs font-bold text-white text-center\"\n :style=\"{ backgroundColor: color }\"\n >\n {{ name }}\n </div>\n </div>\n</template>\n",
|
||||
"type": "registry:component",
|
||||
"target": "components/cursor.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/vue/components/realtime-cursors.vue",
|
||||
"content": "<script setup lang=\"ts\">\nimport { Cursor } from '@/components/cursor'\nimport { useRealtimeCursors } from '@/composables/useRealtimeCursors'\n\nconst THROTTLE_MS = 50\n\nconst props = defineProps<{\n roomName: string\n username: string\n}>()\n\nconst { cursors } = useRealtimeCursors({\n roomName: props.roomName,\n username: props.username,\n throttleMs: THROTTLE_MS,\n})\n</script>\n\n<template>\n <div>\n <Cursor\n v-for=\"(cursor, id) in cursors\"\n :key=\"id\"\n className=\"fixed transition-transform ease-in-out z-50\"\n :style=\"{\n transitionDuration: '20ms',\n top: '0px',\n left: '0px',\n transform: `translate(${cursor.position.x}px, ${cursor.position.y}px)`,\n }\"\n :color=\"cursor.color\"\n :name=\"cursor.user.name\"\n />\n </div>\n</template>\n",
|
||||
"type": "registry:component",
|
||||
"target": "components/realtime-cursors.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/vue/composables/useRealtimeCursors.ts",
|
||||
"content": "import { ref, reactive, onMounted, onUnmounted } from 'vue'\nimport { createClient } from '@/lib/supabase/client'\nimport { REALTIME_SUBSCRIBE_STATES, type RealtimeChannel } from '@supabase/supabase-js'\n\n/**\n * Throttle a callback to a certain delay.\n * It will only call the callback if the delay has passed,\n * using the arguments from the last call.\n */\nfunction useThrottleCallback<Params extends unknown[]>(\n callback: (...args: Params) => void,\n delay: number\n) {\n let lastCall = 0\n let timeout: ReturnType<typeof setTimeout> | null = null\n\n const run = (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall)\n\n if (remainingTime <= 0) {\n if (timeout) {\n clearTimeout(timeout)\n timeout = null\n }\n lastCall = now\n callback(...args)\n } else if (!timeout) {\n timeout = setTimeout(() => {\n lastCall = Date.now()\n timeout = null\n callback(...args)\n }, remainingTime)\n }\n }\n\n const cancel = () => {\n if (timeout) {\n clearTimeout(timeout)\n timeout = null\n }\n }\n\n return { run, cancel }\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () =>\n `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () =>\n Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\nexport type CursorEventPayload = {\n position: { x: number; y: number }\n user: { id: number; name: string }\n color: string\n timestamp: number\n}\n\nexport function useRealtimeCursors({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) {\n const color = generateRandomColor()\n const userId = generateRandomNumber()\n\n const cursors = reactive<Record<string, CursorEventPayload>>({})\n const cursorPayload = ref<CursorEventPayload | null>(null)\n const channelRef = ref<RealtimeChannel | null>(null)\n\n const sendCursor = (event: MouseEvent) => {\n const payload: CursorEventPayload = {\n position: {\n x: event.clientX,\n y: event.clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color,\n timestamp: Date.now(),\n }\n\n cursorPayload.value = payload\n\n channelRef.value?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload,\n })\n }\n\n const { run: handleMouseMove, cancel: cancelThrottle } =\n useThrottleCallback(sendCursor, throttleMs)\n\n onMounted(() => {\n const channel = supabase.channel(roomName)\n\n channel\n .on('system', {}, (payload: CursorEventPayload) => {\n console.error('Realtime system error:', payload)\n\n // Defensive cleanup\n Object.keys(cursors).forEach((k) => delete cursors[k])\n channelRef.value = null\n })\n .on('presence', { event: 'leave' }, ({ leftPresences }) => {\n leftPresences.forEach(({ key }) => {\n delete cursors[key]\n })\n })\n .on('presence', { event: 'join' }, () => {\n if (!cursorPayload.value) return\n\n channelRef.value?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: cursorPayload.value,\n })\n })\n .on('broadcast', { event: EVENT_NAME }, ({ payload }: { payload: CursorEventPayload }) => {\n if (payload.user.id === userId) return\n\n cursors[payload.user.id] = payload\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n try {\n await channel.track({ key: userId })\n channelRef.value = channel\n } catch (err) {\n console.error('Failed to track presence for current user:', err)\n channelRef.value = null\n }\n } else {\n Object.keys(cursors).forEach((k) => delete cursors[k])\n channelRef.value = null\n }\n })\n\n window.addEventListener('mousemove', handleMouseMove)\n })\n\n onUnmounted(() => {\n window.removeEventListener('mousemove', handleMouseMove)\n\n cancelThrottle()\n\n if (channelRef.value) {\n channelRef.value.unsubscribe()\n channelRef.value = null\n }\n\n Object.keys(cursors).forEach((k) => delete cursors[k])\n })\n\n return { cursors }\n}\n",
|
||||
"type": "registry:component",
|
||||
"target": "composables/useRealtimeCursors.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1949,6 +1949,134 @@
|
||||
"@supabase/supabase-js@latest"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dropzone-nuxtjs",
|
||||
"type": "registry:block",
|
||||
"title": "Dropzone (File Upload) for Nuxt and Supabase",
|
||||
"description": "Displays a control for easier uploading of files directly to Supabase Storage.",
|
||||
"registryDependencies": [
|
||||
"button"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/dropzone/nuxtjs/app/components/dropzone.vue",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/dropzone.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/nuxtjs/app/components/dropzone-empty-state.vue",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/dropzone-empty-state.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/nuxtjs/app/components/dropzone-content.vue",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/dropzone-content.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/nuxtjs/app/composables/useSupabaseUpload.ts",
|
||||
"type": "registry:component",
|
||||
"target": "app/composables/useSupabaseUpload.ts"
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
"@supabase/supabase-js@latest",
|
||||
"@vueuse/core",
|
||||
"lucide-vue-next"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dropzone-vue",
|
||||
"type": "registry:block",
|
||||
"title": "Dropzone (File Upload) for Vue and Supabase",
|
||||
"description": "Displays a control for easier uploading of files directly to Supabase Storage.",
|
||||
"registryDependencies": [
|
||||
"button"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/dropzone/vue/components/dropzone.vue",
|
||||
"type": "registry:component",
|
||||
"target": "components/dropzone.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/vue/components/dropzone-empty-state.vue",
|
||||
"type": "registry:component",
|
||||
"target": "components/dropzone-empty-state.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/vue/components/dropzone-content.vue",
|
||||
"type": "registry:component",
|
||||
"target": "components/dropzone-content.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/vue/composables/useSupabaseUpload.ts",
|
||||
"type": "registry:component",
|
||||
"target": "composables/useSupabaseUpload.ts"
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
"@supabase/supabase-js@latest",
|
||||
"@vueuse/core",
|
||||
"lucide-vue-next"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "realtime-cursor-nuxtjs",
|
||||
"type": "registry:component",
|
||||
"title": "Realtime Cursor for Nuxt and Supabase",
|
||||
"description": "Component which renders realtime cursors from other users in a room.",
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/nuxtjs/app/components/cursor.vue",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/cursor.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/nuxtjs/app/components/realtime-cursors.vue",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/realtime-cursors.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/nuxtjs/app/composables/useRealtimeCursors.ts",
|
||||
"type": "registry:component",
|
||||
"target": "app/composables/useRealtimeCursors.ts"
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
"@supabase/supabase-js@latest",
|
||||
"@vueuse/core",
|
||||
"lucide-vue-next"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "realtime-cursor-vue",
|
||||
"type": "registry:component",
|
||||
"title": "Realtime Cursor for Vue and Supabase",
|
||||
"description": "Component which renders realtime cursors from other users in a room.",
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/vue/components/cursor.vue",
|
||||
"type": "registry:component",
|
||||
"target": "components/cursor.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/vue/components/realtime-cursors.vue",
|
||||
"type": "registry:component",
|
||||
"target": "components/realtime-cursors.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/vue/composables/useRealtimeCursors.ts",
|
||||
"type": "registry:component",
|
||||
"target": "composables/useRealtimeCursors.ts"
|
||||
}
|
||||
],
|
||||
"dependencies": [
|
||||
"@supabase/supabase-js@latest",
|
||||
"@vueuse/core",
|
||||
"lucide-vue-next"
|
||||
]
|
||||
},
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
||||
"name": "ai-editor-rules",
|
||||
|
||||
+10
-8
@@ -10,15 +10,17 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"h3": "^1.15.5",
|
||||
"nuxt": "^4.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"clsx": "^2.1.1",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1",
|
||||
"@supabase/ssr": "^0.7.0",
|
||||
"@supabase/supabase-js": "catalog:"
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"h3": "^1.15.5",
|
||||
"lucide-vue-next": "^0.562.0",
|
||||
"nuxt": "^4.0.3",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"shadcn": "^3.0.0",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle, File, Loader2, X } from 'lucide-vue-next'
|
||||
import { formatBytes, useDropzoneContext } from './dropzone.vue'
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{ className?: string }>()
|
||||
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
onUpload,
|
||||
loading,
|
||||
successes,
|
||||
errors,
|
||||
maxFileSize,
|
||||
maxFiles,
|
||||
isSuccess,
|
||||
} = useDropzoneContext()
|
||||
|
||||
const exceedMaxFiles = computed(() => files.length > maxFiles)
|
||||
|
||||
const fileErrorMessages = computed(() => new Map(errors.map(e => [e.name, e.message])))
|
||||
|
||||
function formatFileErrors(file: typeof files[number]) {
|
||||
if (!file.errors || file.errors.length === 0) return ''
|
||||
return file.errors
|
||||
.map(e =>
|
||||
e.message.startsWith('File is larger than')
|
||||
? `File is larger than ${formatBytes(maxFileSize)} (Size: ${formatBytes(file.size)})`
|
||||
: e.message
|
||||
)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
function handleRemoveFile(filename: string) {
|
||||
setFiles(files.filter(f => f.name !== filename))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col', props.className)">
|
||||
<!-- Success State -->
|
||||
<div v-if="isSuccess" class="flex flex-row items-center gap-x-2 justify-center" role="status" aria-live="polite">
|
||||
<CheckCircle size="16" class="text-primary" aria-hidden="true"/>
|
||||
<p class="text-primary text-sm">
|
||||
Successfully uploaded {{ files.length }} file{{ files.length > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(file, idx) in files"
|
||||
:key="file?.name + '-' + file?.lastModified + '-' + file?.size || file?.name + '-' + idx"
|
||||
class="flex items-center gap-x-4 border-b py-2 first:mt-4 last:mb-4"
|
||||
>
|
||||
<div v-if="file.type.startsWith('image/')"
|
||||
class="h-10 w-10 rounded border overflow-hidden shrink-0 bg-muted flex items-center justify-center">
|
||||
<img :src="file.preview" :alt="file.name" class="object-cover" />
|
||||
</div>
|
||||
|
||||
<div v-else class="h-10 w-10 rounded border bg-muted flex items-center justify-center">
|
||||
<File size="18" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div class="shrink grow flex flex-col items-start truncate">
|
||||
<p class="text-sm truncate max-w-full" :title="file.name">
|
||||
{{ file.name }}
|
||||
</p>
|
||||
|
||||
<!-- ARIA live region for status messages -->
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
<!-- Errors -->
|
||||
<p v-if="file.errors.length > 0" class="text-xs text-destructive">
|
||||
{{ formatFileErrors(file) }}
|
||||
</p>
|
||||
|
||||
<!-- Uploading -->
|
||||
<p v-else-if="loading" class="text-xs text-muted-foreground">Uploading file...</p>
|
||||
|
||||
<!-- Failed -->
|
||||
<p v-else-if="fileErrorMessages.has(file.name)" class="text-xs text-destructive">
|
||||
Failed to upload: {{ fileErrorMessages.get(file.name) }}
|
||||
</p>
|
||||
|
||||
<!-- Success -->
|
||||
<p v-else-if="successes.includes(file.name)" class="text-xs text-primary">
|
||||
Successfully uploaded file
|
||||
</p>
|
||||
|
||||
<!-- Normal -->
|
||||
<p v-else class="text-xs text-muted-foreground">
|
||||
{{ formatBytes(file.size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="!loading && !successes.includes(file.name)"
|
||||
aria-label="Remove file Button"
|
||||
size="icon"
|
||||
variant="link"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
@click="handleRemoveFile(file.name)"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Too many files -->
|
||||
<p v-if="exceedMaxFiles" class="text-sm text-left mt-2 text-destructive">
|
||||
You may upload only up to {{ maxFiles }} files, please remove
|
||||
{{ files.length - maxFiles }} file(s).
|
||||
</p>
|
||||
|
||||
<!-- Upload button -->
|
||||
<div v-if="files.length > 0 && !exceedMaxFiles" class="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="files.some(f => f.errors.length) || loading"
|
||||
@click="onUpload"
|
||||
>
|
||||
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<template v-if="loading">Uploading...</template>
|
||||
<template v-else>Upload files</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { Upload } from 'lucide-vue-next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useDropzoneContext, formatBytes } from './dropzone.vue'
|
||||
|
||||
const props = defineProps<{ className?: string }>()
|
||||
|
||||
const { maxFiles, maxFileSize, inputRef, isSuccess } = useDropzoneContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!isSuccess" :class="cn('flex flex-col items-center gap-y-2', props.className)">
|
||||
<Upload size="20" class="text-muted-foreground" aria-hidden="true" />
|
||||
|
||||
<p class="text-sm">
|
||||
Upload{{ maxFiles > 1 ? ` ${maxFiles}` : '' }} file{{ maxFiles !== 1 ? 's' : '' }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col items-center gap-y-1">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Drag and drop or
|
||||
<button
|
||||
type="button"
|
||||
class="underline cursor-pointer hover:text-foreground"
|
||||
@click="inputRef?.click()"
|
||||
>
|
||||
select file{{ maxFiles !== 1 ? 's' : '' }}
|
||||
</button>
|
||||
to upload
|
||||
</p>
|
||||
|
||||
<p v-if="maxFileSize !== Infinity" class="text-xs text-muted-foreground">
|
||||
Maximum file size: {{ formatBytes(maxFileSize) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { provide, inject } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface File {
|
||||
name: string
|
||||
errors: Array<{ message: string }>
|
||||
size: number
|
||||
lastModified?: number
|
||||
type?: string
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export interface DropzoneProps {
|
||||
className?: string
|
||||
isDragActive?: boolean
|
||||
isDragReject?: boolean
|
||||
isSuccess?: boolean
|
||||
maxFiles?: number
|
||||
maxFileSize?: number
|
||||
inputRef?: HTMLInputElement | null
|
||||
setFiles: (files: File[]) => void
|
||||
onUpload: () => Promise<void>
|
||||
loading?: boolean
|
||||
successes: string[]
|
||||
errors: Array<{ name: string; message: string }>
|
||||
files: File[]
|
||||
getRootProps: (options?: Record<string, unknown>) => Record<string, unknown>
|
||||
getInputProps: () => Record<string, unknown>
|
||||
}
|
||||
|
||||
export const formatBytes = (
|
||||
bytes: number,
|
||||
decimals = 2,
|
||||
size?: 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB'
|
||||
) => {
|
||||
const k = 1000
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
if (!bytes || bytes < 0) return size ? `0 ${size}` : '0 bytes'
|
||||
|
||||
const i = size
|
||||
? Math.max(0, sizes.indexOf(size))
|
||||
: Math.max(0, Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const DropzoneContext = Symbol('DropzoneContext')
|
||||
|
||||
export function useDropzoneContext() {
|
||||
const ctx = inject<DropzoneProps>(DropzoneContext)
|
||||
if (!ctx) throw new Error('useDropzoneContext must be used within <Dropzone>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
const props = defineProps<DropzoneProps>()
|
||||
|
||||
provide(DropzoneContext, props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-bind="
|
||||
props.getRootProps({
|
||||
class: cn(
|
||||
'border-2 border-gray-300 rounded-lg p-6 text-center bg-card transition-colors duration-300 text-foreground',
|
||||
props.className,
|
||||
props.isSuccess ? 'border-solid' : 'border-dashed',
|
||||
props.isDragActive && 'border-primary bg-primary/10',
|
||||
((props.isDragActive && props.isDragReject) ||
|
||||
(props.errors.length > 0 && !props.isSuccess) ||
|
||||
props.files.some((f) => f.errors.length !== 0)) &&
|
||||
'border-destructive bg-destructive/10'
|
||||
),
|
||||
})
|
||||
"
|
||||
>
|
||||
<input v-bind="props.getInputProps()" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { useDropZone } from '@vueuse/core'
|
||||
// @ts-ignore
|
||||
import { createClient } from "@/lib/supabase/client"
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
export interface FileWithPreview extends File {
|
||||
preview?: string
|
||||
errors: { code: string; message: string }[]
|
||||
}
|
||||
|
||||
export type UseSupabaseUploadOptions = {
|
||||
bucketName: string
|
||||
path?: string
|
||||
allowedMimeTypes?: string[]
|
||||
maxFileSize?: number
|
||||
maxFiles?: number
|
||||
cacheControl?: number
|
||||
upsert?: boolean
|
||||
}
|
||||
|
||||
function validateFileType(file: File, allowedTypes: string[]) {
|
||||
if (!allowedTypes.length) return []
|
||||
const isValid = allowedTypes.some(t =>
|
||||
t.endsWith('/*')
|
||||
? file.type.startsWith(t.replace('/*', ''))
|
||||
: file.type === t
|
||||
)
|
||||
return isValid
|
||||
? []
|
||||
: [{ code: 'invalid-type', message: 'Invalid file type' }]
|
||||
}
|
||||
|
||||
function validateFileSize(file: File, maxSize: number) {
|
||||
return file.size > maxSize
|
||||
? [{ code: 'file-too-large', message: `File is larger than allowed size` }]
|
||||
: []
|
||||
}
|
||||
|
||||
export function useSupabaseUpload(options: UseSupabaseUploadOptions) {
|
||||
const {
|
||||
bucketName,
|
||||
path,
|
||||
allowedMimeTypes = [],
|
||||
maxFileSize = Number.POSITIVE_INFINITY,
|
||||
maxFiles = 1,
|
||||
cacheControl = 3600,
|
||||
upsert = false,
|
||||
} = options
|
||||
|
||||
const files = ref<FileWithPreview[]>([])
|
||||
const loading = ref(false)
|
||||
const errors = ref<{ name: string; message: string }[]>([])
|
||||
const successes = ref<string[]>([])
|
||||
|
||||
const isSuccess = computed(() => {
|
||||
if (!errors.value.length && !successes.value.length) return false
|
||||
return !errors.value.length && successes.value.length === files.value.length
|
||||
})
|
||||
|
||||
const dropZoneRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||
onDrop(droppedFiles: File[] | null) {
|
||||
if (!droppedFiles) return
|
||||
|
||||
const newFiles: FileWithPreview[] = droppedFiles.map(file => ({
|
||||
...(file as FileWithPreview),
|
||||
preview: URL.createObjectURL(file),
|
||||
errors: [
|
||||
...validateFileType(file, allowedMimeTypes),
|
||||
...validateFileSize(file, maxFileSize),
|
||||
],
|
||||
}))
|
||||
|
||||
files.value = [...files.value, ...newFiles]
|
||||
},
|
||||
})
|
||||
|
||||
const onUpload = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const filesWithErrors = errors.value.map(e => e.name)
|
||||
|
||||
const filesToUpload =
|
||||
filesWithErrors.length > 0
|
||||
? files.value.filter(
|
||||
f =>
|
||||
filesWithErrors.includes(f.name) ||
|
||||
!successes.value.includes(f.name)
|
||||
)
|
||||
: files.value
|
||||
|
||||
const responses = await Promise.all(
|
||||
filesToUpload.map(async file => {
|
||||
const { error } = await supabase.storage
|
||||
.from(bucketName)
|
||||
.upload(path ? `${path}/${file.name}` : file.name, file, {
|
||||
cacheControl: cacheControl.toString(),
|
||||
upsert,
|
||||
})
|
||||
|
||||
return error
|
||||
? { name: file.name, message: error.message }
|
||||
: { name: file.name, message: undefined }
|
||||
})
|
||||
)
|
||||
|
||||
errors.value = responses.filter((r): r is { name: string; message: string } => r.message !== undefined)
|
||||
|
||||
const successful = responses
|
||||
.filter(r => !r.message)
|
||||
.map(r => r.name)
|
||||
|
||||
successes.value = Array.from(
|
||||
new Set([...successes.value, ...successful])
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Upload failed unexpectedly:', err)
|
||||
|
||||
errors.value.push({
|
||||
name: 'upload',
|
||||
message: 'An unexpected error occurred during upload.',
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
watch(
|
||||
() => files.value.length,
|
||||
() => {
|
||||
if (!files.value.length) {
|
||||
errors.value = []
|
||||
successes.value = []
|
||||
}
|
||||
|
||||
if (files.value.length > maxFiles) {
|
||||
errors.value.push({
|
||||
name: 'files',
|
||||
message: `You may upload up to ${maxFiles} files`,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
files,
|
||||
(newFiles, oldFiles) => {
|
||||
const newPreviews = new Set(newFiles.map(f => f.preview))
|
||||
oldFiles.forEach(file => {
|
||||
if (file.preview && !newPreviews.has(file.preview)) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
files.value.forEach(file => {
|
||||
if (file.preview) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
dropZoneRef,
|
||||
isOverDropZone,
|
||||
|
||||
files,
|
||||
setFiles: (v: FileWithPreview[]) => (files.value = v),
|
||||
|
||||
errors,
|
||||
setErrors: (v: { name: string; message: string }[]) => (errors.value = v),
|
||||
|
||||
successes,
|
||||
isSuccess,
|
||||
loading,
|
||||
onUpload,
|
||||
|
||||
maxFileSize,
|
||||
maxFiles,
|
||||
allowedMimeTypes,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "dropzone-nuxtjs",
|
||||
"type": "registry:block",
|
||||
"title": "Dropzone (File Upload) for Nuxt and Supabase",
|
||||
"description": "Displays a control for easier uploading of files directly to Supabase Storage.",
|
||||
"registryDependencies": ["button"],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/dropzone/nuxtjs/app/components/dropzone.vue",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/dropzone.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/nuxtjs/app/components/dropzone-empty-state.vue",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/dropzone-empty-state.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/nuxtjs/app/components/dropzone-content.vue",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/dropzone-content.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/nuxtjs/app/composables/useSupabaseUpload.ts",
|
||||
"type": "registry:component",
|
||||
"target": "app/composables/useSupabaseUpload.ts"
|
||||
}
|
||||
],
|
||||
"dependencies": ["@supabase/supabase-js@latest", "@vueuse/core", "lucide-vue-next"]
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle, File, Loader2, X } from 'lucide-vue-next'
|
||||
import { formatBytes, useDropzoneContext } from './dropzone.vue'
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{ className?: string }>()
|
||||
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
onUpload,
|
||||
loading,
|
||||
successes,
|
||||
errors,
|
||||
maxFileSize,
|
||||
maxFiles,
|
||||
isSuccess,
|
||||
} = useDropzoneContext()
|
||||
|
||||
const exceedMaxFiles = computed(() => files.length > maxFiles)
|
||||
|
||||
const fileErrorMessages = computed(() => new Map(errors.map(e => [e.name, e.message])))
|
||||
|
||||
function formatFileErrors(file: typeof files[number]) {
|
||||
if (!file.errors || file.errors.length === 0) return ''
|
||||
return file.errors
|
||||
.map(e =>
|
||||
e.message.startsWith('File is larger than')
|
||||
? `File is larger than ${formatBytes(maxFileSize)} (Size: ${formatBytes(file.size)})`
|
||||
: e.message
|
||||
)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
function handleRemoveFile(filename: string) {
|
||||
setFiles(files.filter(f => f.name !== filename))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col', props.className)">
|
||||
<!-- Success State -->
|
||||
<div v-if="isSuccess" class="flex flex-row items-center gap-x-2 justify-center" role="status" aria-live="polite">
|
||||
<CheckCircle size="16" class="text-primary" aria-hidden="true"/>
|
||||
<p class="text-primary text-sm">
|
||||
Successfully uploaded {{ files.length }} file{{ files.length > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(file, idx) in files"
|
||||
:key="file?.name + '-' + file?.lastModified + '-' + file?.size || file?.name + '-' + idx"
|
||||
class="flex items-center gap-x-4 border-b py-2 first:mt-4 last:mb-4"
|
||||
>
|
||||
<div v-if="file.type.startsWith('image/')"
|
||||
class="h-10 w-10 rounded border overflow-hidden shrink-0 bg-muted flex items-center justify-center">
|
||||
<img :src="file.preview" :alt="file.name" class="object-cover" />
|
||||
</div>
|
||||
|
||||
<div v-else class="h-10 w-10 rounded border bg-muted flex items-center justify-center">
|
||||
<File size="18" aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<div class="shrink grow flex flex-col items-start truncate">
|
||||
<p class="text-sm truncate max-w-full" :title="file.name">
|
||||
{{ file.name }}
|
||||
</p>
|
||||
|
||||
<!-- ARIA live region for status messages -->
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
<!-- Errors -->
|
||||
<p v-if="file.errors.length > 0" class="text-xs text-destructive">
|
||||
{{ formatFileErrors(file) }}
|
||||
</p>
|
||||
|
||||
<!-- Uploading -->
|
||||
<p v-else-if="loading" class="text-xs text-muted-foreground">Uploading file...</p>
|
||||
|
||||
<!-- Failed -->
|
||||
<p v-else-if="fileErrorMessages.has(file.name)" class="text-xs text-destructive">
|
||||
Failed to upload: {{ fileErrorMessages.get(file.name) }}
|
||||
</p>
|
||||
|
||||
<!-- Success -->
|
||||
<p v-else-if="successes.includes(file.name)" class="text-xs text-primary">
|
||||
Successfully uploaded file
|
||||
</p>
|
||||
|
||||
<!-- Normal -->
|
||||
<p v-else class="text-xs text-muted-foreground">
|
||||
{{ formatBytes(file.size) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="!loading && !successes.includes(file.name)"
|
||||
aria-label="Remove file Button"
|
||||
size="icon"
|
||||
variant="link"
|
||||
class="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
@click="handleRemoveFile(file.name)"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Too many files -->
|
||||
<p v-if="exceedMaxFiles" class="text-sm text-left mt-2 text-destructive">
|
||||
You may upload only up to {{ maxFiles }} files, please remove
|
||||
{{ files.length - maxFiles }} file(s).
|
||||
</p>
|
||||
|
||||
<!-- Upload button -->
|
||||
<div v-if="files.length > 0 && !exceedMaxFiles" class="mt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="files.some(f => f.errors.length) || loading"
|
||||
@click="onUpload"
|
||||
>
|
||||
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
<template v-if="loading">Uploading...</template>
|
||||
<template v-else>Upload files</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { Upload } from 'lucide-vue-next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useDropzoneContext, formatBytes } from './dropzone.vue'
|
||||
|
||||
const props = defineProps<{ className?: string }>()
|
||||
|
||||
const { maxFiles, maxFileSize, inputRef, isSuccess } = useDropzoneContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!isSuccess" :class="cn('flex flex-col items-center gap-y-2', props.className)">
|
||||
<Upload size="20" class="text-muted-foreground" aria-hidden="true"/>
|
||||
|
||||
<p class="text-sm">
|
||||
Upload{{ maxFiles > 1 ? ` ${maxFiles}` : '' }} file{{ maxFiles !== 1 ? 's' : '' }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col items-center gap-y-1">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Drag and drop or
|
||||
<button
|
||||
type="button"
|
||||
class="underline cursor-pointer hover:text-foreground"
|
||||
@click="inputRef?.click()"
|
||||
>
|
||||
select file{{ maxFiles !== 1 ? 's' : '' }}
|
||||
</button>
|
||||
to upload
|
||||
</p>
|
||||
|
||||
<p v-if="maxFileSize !== Infinity" class="text-xs text-muted-foreground">
|
||||
Maximum file size: {{ formatBytes(maxFileSize) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { provide, inject } from 'vue'
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface File {
|
||||
name: string
|
||||
errors: Array<{ message: string }>
|
||||
size: number
|
||||
lastModified?: number
|
||||
type?: string
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export interface DropzoneProps {
|
||||
className?: string
|
||||
isDragActive?: boolean
|
||||
isDragReject?: boolean
|
||||
isSuccess?: boolean
|
||||
maxFiles?: number
|
||||
maxFileSize?: number
|
||||
inputRef?: HTMLInputElement | null
|
||||
setFiles: (files: File[]) => void
|
||||
onUpload: () => Promise<void>
|
||||
loading?: boolean
|
||||
successes: string[]
|
||||
errors: Array<{ name: string; message: string }>
|
||||
files: File[]
|
||||
getRootProps: (options?: Record<string, unknown>) => Record<string, unknown>
|
||||
getInputProps: () => Record<string, unknown>
|
||||
}
|
||||
|
||||
export const formatBytes = (
|
||||
bytes: number,
|
||||
decimals = 2,
|
||||
size?: 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB'
|
||||
) => {
|
||||
const k = 1000
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
if (!bytes || bytes < 0) return size ? `0 ${size}` : '0 bytes'
|
||||
|
||||
const i = size
|
||||
? Math.max(0, sizes.indexOf(size))
|
||||
: Math.max(0, Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
|
||||
const DropzoneContext = Symbol('DropzoneContext')
|
||||
|
||||
export function useDropzoneContext() {
|
||||
const ctx = inject<DropzoneProps>(DropzoneContext)
|
||||
if (!ctx) throw new Error('useDropzoneContext must be used within <Dropzone>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
const props = defineProps<DropzoneProps>()
|
||||
|
||||
provide(DropzoneContext, props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-bind="props.getRootProps({
|
||||
class: cn(
|
||||
'border-2 border-gray-300 rounded-lg p-6 text-center bg-card transition-colors duration-300 text-foreground',
|
||||
props.className,
|
||||
props.isSuccess ? 'border-solid' : 'border-dashed',
|
||||
props.isDragActive && 'border-primary bg-primary/10',
|
||||
((props.isDragActive && props.isDragReject) ||
|
||||
(props.errors.length > 0 && !props.isSuccess) ||
|
||||
props.files.some(f => f.errors.length !== 0))
|
||||
&& 'border-destructive bg-destructive/10'
|
||||
)
|
||||
})"
|
||||
>
|
||||
<input v-bind="props.getInputProps()" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { useDropZone } from '@vueuse/core'
|
||||
// @ts-ignore
|
||||
import { createClient } from "@/lib/supabase/client"
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
export interface FileWithPreview extends File {
|
||||
preview?: string
|
||||
errors: { code: string; message: string }[]
|
||||
}
|
||||
|
||||
export type UseSupabaseUploadOptions = {
|
||||
bucketName: string
|
||||
path?: string
|
||||
allowedMimeTypes?: string[]
|
||||
maxFileSize?: number
|
||||
maxFiles?: number
|
||||
cacheControl?: number
|
||||
upsert?: boolean
|
||||
}
|
||||
|
||||
function validateFileType(file: File, allowedTypes: string[]) {
|
||||
if (!allowedTypes.length) return []
|
||||
const isValid = allowedTypes.some(t =>
|
||||
t.endsWith('/*')
|
||||
? file.type.startsWith(t.replace('/*', ''))
|
||||
: file.type === t
|
||||
)
|
||||
return isValid
|
||||
? []
|
||||
: [{ code: 'invalid-type', message: 'Invalid file type' }]
|
||||
}
|
||||
|
||||
function validateFileSize(file: File, maxSize: number) {
|
||||
return file.size > maxSize
|
||||
? [{ code: 'file-too-large', message: `File is larger than allowed size` }]
|
||||
: []
|
||||
}
|
||||
|
||||
export function useSupabaseUpload(options: UseSupabaseUploadOptions) {
|
||||
const {
|
||||
bucketName,
|
||||
path,
|
||||
allowedMimeTypes = [],
|
||||
maxFileSize = Number.POSITIVE_INFINITY,
|
||||
maxFiles = 1,
|
||||
cacheControl = 3600,
|
||||
upsert = false,
|
||||
} = options
|
||||
|
||||
const files = ref<FileWithPreview[]>([])
|
||||
const loading = ref(false)
|
||||
const errors = ref<{ name: string; message: string }[]>([])
|
||||
const successes = ref<string[]>([])
|
||||
|
||||
const isSuccess = computed(() => {
|
||||
if (!errors.value.length && !successes.value.length) return false
|
||||
return !errors.value.length && successes.value.length === files.value.length
|
||||
})
|
||||
|
||||
const dropZoneRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const { isOverDropZone } = useDropZone(dropZoneRef, {
|
||||
onDrop(droppedFiles: File[] | null) {
|
||||
if (!droppedFiles) return
|
||||
|
||||
const newFiles: FileWithPreview[] = droppedFiles.map(file => ({
|
||||
...(file as FileWithPreview),
|
||||
preview: URL.createObjectURL(file),
|
||||
errors: [
|
||||
...validateFileType(file, allowedMimeTypes),
|
||||
...validateFileSize(file, maxFileSize),
|
||||
],
|
||||
}))
|
||||
|
||||
files.value = [...files.value, ...newFiles]
|
||||
},
|
||||
})
|
||||
|
||||
const onUpload = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const filesWithErrors = errors.value.map(e => e.name)
|
||||
|
||||
const filesToUpload =
|
||||
filesWithErrors.length > 0
|
||||
? files.value.filter(
|
||||
f =>
|
||||
filesWithErrors.includes(f.name) ||
|
||||
!successes.value.includes(f.name)
|
||||
)
|
||||
: files.value
|
||||
|
||||
const responses = await Promise.all(
|
||||
filesToUpload.map(async file => {
|
||||
const { error } = await supabase.storage
|
||||
.from(bucketName)
|
||||
.upload(path ? `${path}/${file.name}` : file.name, file, {
|
||||
cacheControl: cacheControl.toString(),
|
||||
upsert,
|
||||
})
|
||||
|
||||
return error
|
||||
? { name: file.name, message: error.message }
|
||||
: { name: file.name, message: undefined }
|
||||
})
|
||||
)
|
||||
|
||||
errors.value = responses.filter((r): r is { name: string; message: string } => r.message !== undefined)
|
||||
|
||||
const successful = responses
|
||||
.filter(r => !r.message)
|
||||
.map(r => r.name)
|
||||
|
||||
successes.value = Array.from(
|
||||
new Set([...successes.value, ...successful])
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Upload failed unexpectedly:', err)
|
||||
|
||||
errors.value.push({
|
||||
name: 'upload',
|
||||
message: 'An unexpected error occurred during upload.',
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
watch(
|
||||
() => files.value.length,
|
||||
() => {
|
||||
if (!files.value.length) {
|
||||
errors.value = []
|
||||
successes.value = []
|
||||
}
|
||||
|
||||
if (files.value.length > maxFiles) {
|
||||
errors.value.push({
|
||||
name: 'files',
|
||||
message: `You may upload up to ${maxFiles} files`,
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
files,
|
||||
(newFiles, oldFiles) => {
|
||||
const newPreviews = new Set(newFiles.map(f => f.preview))
|
||||
oldFiles.forEach(file => {
|
||||
if (file.preview && !newPreviews.has(file.preview)) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
files.value.forEach(file => {
|
||||
if (file.preview) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
dropZoneRef,
|
||||
isOverDropZone,
|
||||
|
||||
files,
|
||||
setFiles: (v: FileWithPreview[]) => (files.value = v),
|
||||
|
||||
errors,
|
||||
setErrors: (v: { name: string; message: string }[]) => (errors.value = v),
|
||||
|
||||
successes,
|
||||
isSuccess,
|
||||
loading,
|
||||
onUpload,
|
||||
|
||||
maxFileSize,
|
||||
maxFiles,
|
||||
allowedMimeTypes,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "dropzone-vue",
|
||||
"type": "registry:block",
|
||||
"title": "Dropzone (File Upload) for Vue and Supabase",
|
||||
"description": "Displays a control for easier uploading of files directly to Supabase Storage.",
|
||||
"registryDependencies": ["button"],
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/dropzone/vue/components/dropzone.vue",
|
||||
"type": "registry:component",
|
||||
"target": "components/dropzone.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/vue/components/dropzone-empty-state.vue",
|
||||
"type": "registry:component",
|
||||
"target": "components/dropzone-empty-state.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/vue/components/dropzone-content.vue",
|
||||
"type": "registry:component",
|
||||
"target": "components/dropzone-content.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/dropzone/vue/composables/useSupabaseUpload.ts",
|
||||
"type": "registry:component",
|
||||
"target": "composables/useSupabaseUpload.ts"
|
||||
}
|
||||
],
|
||||
"dependencies": ["@supabase/supabase-js@latest", "@vueuse/core", "lucide-vue-next"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { MousePointer2 } from 'lucide-vue-next'
|
||||
import type { CSSProperties } from 'vue'
|
||||
// @ts-ignore
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineProps<{
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
color: string
|
||||
name: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('pointer-events-none', className)"
|
||||
:style="style"
|
||||
>
|
||||
<MousePointer2
|
||||
:color="color"
|
||||
:fill="color"
|
||||
:size="30"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="mt-1 px-2 py-1 rounded text-xs font-bold text-white text-center"
|
||||
:style="{ backgroundColor: color }"
|
||||
>
|
||||
{{ name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
// @ts-ignore
|
||||
import { Cursor } from '@/components/cursor'
|
||||
// @ts-ignore
|
||||
import { useRealtimeCursors } from '@/composables/useRealtimeCursors'
|
||||
|
||||
const THROTTLE_MS = 50
|
||||
|
||||
const props = defineProps<{
|
||||
roomName: string
|
||||
username: string
|
||||
}>()
|
||||
|
||||
const { cursors } = useRealtimeCursors({
|
||||
roomName: props.roomName,
|
||||
username: props.username,
|
||||
throttleMs: THROTTLE_MS,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Cursor
|
||||
v-for="(cursor, id) in cursors"
|
||||
:key="id"
|
||||
className="fixed transition-transform ease-in-out z-50"
|
||||
:style="{
|
||||
transitionDuration: '20ms',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
transform: `translate(${cursor.position.x}px, ${cursor.position.y}px)`,
|
||||
}"
|
||||
:color="cursor.color"
|
||||
:name="cursor.user.name"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { REALTIME_SUBSCRIBE_STATES, type RealtimeChannel } from '@supabase/supabase-js'
|
||||
// @ts-ignore
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
/**
|
||||
* Throttle a callback to a certain delay.
|
||||
* It will only call the callback if the delay has passed,
|
||||
* using the arguments from the last call.
|
||||
*/
|
||||
function useThrottleCallback<Params extends unknown[]>(
|
||||
callback: (...args: Params) => void,
|
||||
delay: number
|
||||
) {
|
||||
let lastCall = 0
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const run = (...args: Params) => {
|
||||
const now = Date.now()
|
||||
const remainingTime = delay - (now - lastCall)
|
||||
|
||||
if (remainingTime <= 0) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
lastCall = now
|
||||
callback(...args)
|
||||
} else if (!timeout) {
|
||||
timeout = setTimeout(() => {
|
||||
lastCall = Date.now()
|
||||
timeout = null
|
||||
callback(...args)
|
||||
}, remainingTime)
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
}
|
||||
|
||||
return { run, cancel }
|
||||
}
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const generateRandomColor = () =>
|
||||
`hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`
|
||||
|
||||
const generateRandomNumber = () =>
|
||||
Math.floor(Math.random() * 100)
|
||||
|
||||
const EVENT_NAME = 'realtime-cursor-move'
|
||||
|
||||
export type CursorEventPayload = {
|
||||
position: { x: number; y: number }
|
||||
user: { id: number; name: string }
|
||||
color: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export function useRealtimeCursors({
|
||||
roomName,
|
||||
username,
|
||||
throttleMs,
|
||||
}: {
|
||||
roomName: string
|
||||
username: string
|
||||
throttleMs: number
|
||||
}) {
|
||||
const color = generateRandomColor()
|
||||
const userId = generateRandomNumber()
|
||||
|
||||
const cursors = reactive<Record<string, CursorEventPayload>>({})
|
||||
const cursorPayload = ref<CursorEventPayload | null>(null)
|
||||
const channelRef = ref<RealtimeChannel | null>(null)
|
||||
|
||||
const sendCursor = (event: MouseEvent) => {
|
||||
const payload: CursorEventPayload = {
|
||||
position: {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
},
|
||||
user: {
|
||||
id: userId,
|
||||
name: username,
|
||||
},
|
||||
color,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
cursorPayload.value = payload
|
||||
|
||||
channelRef.value?.send({
|
||||
type: 'broadcast',
|
||||
event: EVENT_NAME,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
const { run: handleMouseMove, cancel: cancelThrottle } =
|
||||
useThrottleCallback(sendCursor, throttleMs)
|
||||
|
||||
onMounted(() => {
|
||||
const channel = supabase.channel(roomName)
|
||||
|
||||
channel
|
||||
.on('system', {}, (payload: CursorEventPayload) => {
|
||||
console.error('Realtime system error:', payload)
|
||||
|
||||
// Defensive cleanup
|
||||
Object.keys(cursors).forEach((k) => delete cursors[k])
|
||||
channelRef.value = null
|
||||
})
|
||||
.on('presence', { event: 'leave' }, ({ leftPresences }: { leftPresences: Array<{ key: string }> }) => {
|
||||
leftPresences.forEach(({ key }) => {
|
||||
delete cursors[key]
|
||||
})
|
||||
})
|
||||
.on('presence', { event: 'join' }, () => {
|
||||
if (!cursorPayload.value) return
|
||||
|
||||
channelRef.value?.send({
|
||||
type: 'broadcast',
|
||||
event: EVENT_NAME,
|
||||
payload: cursorPayload.value,
|
||||
})
|
||||
})
|
||||
.on('broadcast', { event: EVENT_NAME }, ({ payload }: { payload: CursorEventPayload }) => {
|
||||
if (payload.user.id === userId) return
|
||||
|
||||
cursors[payload.user.id] = payload
|
||||
})
|
||||
.subscribe(async (status: REALTIME_SUBSCRIBE_STATES) => {
|
||||
if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {
|
||||
try {
|
||||
await channel.track({ key: userId })
|
||||
channelRef.value = channel
|
||||
} catch (err) {
|
||||
console.error('Failed to track presence for current user:', err)
|
||||
channelRef.value = null
|
||||
}
|
||||
} else {
|
||||
Object.keys(cursors).forEach((k) => delete cursors[k])
|
||||
channelRef.value = null
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
|
||||
cancelThrottle()
|
||||
|
||||
if (channelRef.value) {
|
||||
channelRef.value.unsubscribe()
|
||||
channelRef.value = null
|
||||
}
|
||||
|
||||
Object.keys(cursors).forEach((k) => delete cursors[k])
|
||||
})
|
||||
|
||||
return { cursors }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "realtime-cursor-nuxtjs",
|
||||
"type": "registry:component",
|
||||
"title": "Realtime Cursor for Nuxt and Supabase",
|
||||
"description": "Component which renders realtime cursors from other users in a room.",
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/nuxtjs/app/components/cursor.vue",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/cursor.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/nuxtjs/app/components/realtime-cursors.vue",
|
||||
"type": "registry:component",
|
||||
"target": "app/components/realtime-cursors.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/nuxtjs/app/composables/useRealtimeCursors.ts",
|
||||
"type": "registry:component",
|
||||
"target": "app/composables/useRealtimeCursors.ts"
|
||||
}
|
||||
],
|
||||
"dependencies": ["@supabase/supabase-js@latest", "@vueuse/core", "lucide-vue-next"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { MousePointer2 } from 'lucide-vue-next'
|
||||
import type { CSSProperties } from 'vue'
|
||||
// @ts-ignore
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineProps<{
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
color: string
|
||||
name: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('pointer-events-none', className)"
|
||||
:style="style"
|
||||
>
|
||||
<MousePointer2
|
||||
:color="color"
|
||||
:fill="color"
|
||||
:size="30"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="mt-1 px-2 py-1 rounded text-xs font-bold text-white text-center"
|
||||
:style="{ backgroundColor: color }"
|
||||
>
|
||||
{{ name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
// @ts-ignore
|
||||
import { Cursor } from '@/components/cursor'
|
||||
// @ts-ignore
|
||||
import { useRealtimeCursors } from '@/composables/useRealtimeCursors'
|
||||
|
||||
const THROTTLE_MS = 50
|
||||
|
||||
const props = defineProps<{
|
||||
roomName: string
|
||||
username: string
|
||||
}>()
|
||||
|
||||
const { cursors } = useRealtimeCursors({
|
||||
roomName: props.roomName,
|
||||
username: props.username,
|
||||
throttleMs: THROTTLE_MS,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Cursor
|
||||
v-for="(cursor, id) in cursors"
|
||||
:key="id"
|
||||
className="fixed transition-transform ease-in-out z-50"
|
||||
:style="{
|
||||
transitionDuration: '20ms',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
transform: `translate(${cursor.position.x}px, ${cursor.position.y}px)`,
|
||||
}"
|
||||
:color="cursor.color"
|
||||
:name="cursor.user.name"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,169 @@
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { REALTIME_SUBSCRIBE_STATES, type RealtimeChannel } from '@supabase/supabase-js'
|
||||
// @ts-ignore
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
/**
|
||||
* Throttle a callback to a certain delay.
|
||||
* It will only call the callback if the delay has passed,
|
||||
* using the arguments from the last call.
|
||||
*/
|
||||
function useThrottleCallback<Params extends unknown[]>(
|
||||
callback: (...args: Params) => void,
|
||||
delay: number
|
||||
) {
|
||||
let lastCall = 0
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const run = (...args: Params) => {
|
||||
const now = Date.now()
|
||||
const remainingTime = delay - (now - lastCall)
|
||||
|
||||
if (remainingTime <= 0) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
lastCall = now
|
||||
callback(...args)
|
||||
} else if (!timeout) {
|
||||
timeout = setTimeout(() => {
|
||||
lastCall = Date.now()
|
||||
timeout = null
|
||||
callback(...args)
|
||||
}, remainingTime)
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
}
|
||||
|
||||
return { run, cancel }
|
||||
}
|
||||
|
||||
const supabase = createClient()
|
||||
|
||||
const generateRandomColor = () =>
|
||||
`hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`
|
||||
|
||||
const generateRandomNumber = () =>
|
||||
Math.floor(Math.random() * 100)
|
||||
|
||||
const EVENT_NAME = 'realtime-cursor-move'
|
||||
|
||||
export type CursorEventPayload = {
|
||||
position: { x: number; y: number }
|
||||
user: { id: number; name: string }
|
||||
color: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export function useRealtimeCursors({
|
||||
roomName,
|
||||
username,
|
||||
throttleMs,
|
||||
}: {
|
||||
roomName: string
|
||||
username: string
|
||||
throttleMs: number
|
||||
}) {
|
||||
const color = generateRandomColor()
|
||||
const userId = generateRandomNumber()
|
||||
|
||||
const cursors = reactive<Record<string, CursorEventPayload>>({})
|
||||
const cursorPayload = ref<CursorEventPayload | null>(null)
|
||||
const channelRef = ref<RealtimeChannel | null>(null)
|
||||
|
||||
const sendCursor = (event: MouseEvent) => {
|
||||
const payload: CursorEventPayload = {
|
||||
position: {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
},
|
||||
user: {
|
||||
id: userId,
|
||||
name: username,
|
||||
},
|
||||
color,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
cursorPayload.value = payload
|
||||
|
||||
channelRef.value?.send({
|
||||
type: 'broadcast',
|
||||
event: EVENT_NAME,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
|
||||
const { run: handleMouseMove, cancel: cancelThrottle } =
|
||||
useThrottleCallback(sendCursor, throttleMs)
|
||||
|
||||
onMounted(() => {
|
||||
const channel = supabase.channel(roomName)
|
||||
|
||||
channel
|
||||
.on('system', {}, (payload: CursorEventPayload) => {
|
||||
console.error('Realtime system error:', payload)
|
||||
|
||||
// Defensive cleanup
|
||||
Object.keys(cursors).forEach((k) => delete cursors[k])
|
||||
channelRef.value = null
|
||||
})
|
||||
.on('presence', { event: 'leave' }, ({ leftPresences }: { leftPresences: Array<{ key: string }> }) => {
|
||||
leftPresences.forEach(({ key }) => {
|
||||
delete cursors[key]
|
||||
})
|
||||
})
|
||||
.on('presence', { event: 'join' }, () => {
|
||||
if (!cursorPayload.value) return
|
||||
|
||||
channelRef.value?.send({
|
||||
type: 'broadcast',
|
||||
event: EVENT_NAME,
|
||||
payload: cursorPayload.value,
|
||||
})
|
||||
})
|
||||
.on('broadcast', { event: EVENT_NAME }, ({ payload }: { payload: CursorEventPayload }) => {
|
||||
if (payload.user.id === userId) return
|
||||
|
||||
cursors[payload.user.id] = payload
|
||||
})
|
||||
.subscribe(async (status: REALTIME_SUBSCRIBE_STATES) => {
|
||||
if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {
|
||||
try {
|
||||
await channel.track({ key: userId })
|
||||
channelRef.value = channel
|
||||
} catch (err) {
|
||||
console.error('Failed to track presence for current user:', err)
|
||||
channelRef.value = null
|
||||
}
|
||||
} else {
|
||||
Object.keys(cursors).forEach((k) => delete cursors[k])
|
||||
channelRef.value = null
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
|
||||
cancelThrottle()
|
||||
|
||||
if (channelRef.value) {
|
||||
channelRef.value.unsubscribe()
|
||||
channelRef.value = null
|
||||
}
|
||||
|
||||
Object.keys(cursors).forEach((k) => delete cursors[k])
|
||||
})
|
||||
|
||||
return { cursors }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "realtime-cursor-vue",
|
||||
"type": "registry:component",
|
||||
"title": "Realtime Cursor for Vue and Supabase",
|
||||
"description": "Component which renders realtime cursors from other users in a room.",
|
||||
"files": [
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/vue/components/cursor.vue",
|
||||
"type": "registry:component",
|
||||
"target": "components/cursor.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/vue/components/realtime-cursors.vue",
|
||||
"type": "registry:component",
|
||||
"target": "components/realtime-cursors.vue"
|
||||
},
|
||||
{
|
||||
"path": "registry/default/realtime-cursor/vue/composables/useRealtimeCursors.ts",
|
||||
"type": "registry:component",
|
||||
"target": "composables/useRealtimeCursors.ts"
|
||||
}
|
||||
],
|
||||
"dependencies": ["@supabase/supabase-js@latest", "@vueuse/core", "lucide-vue-next"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type RegistryItem } from 'shadcn/schema'
|
||||
|
||||
import nuxtjs from './default/dropzone/nuxtjs/registry-item.json' with { type: 'json' }
|
||||
import vue from './default/dropzone/vue/registry-item.json' with { type: 'json' }
|
||||
|
||||
export const dropzone = [nuxtjs, vue] as RegistryItem[]
|
||||
@@ -1,7 +1,9 @@
|
||||
import { clients } from './clients'
|
||||
import { dropzone } from './dropzone'
|
||||
import { passwordBasedAuth } from './password-based-auth'
|
||||
import { realtimeCursor } from './realtime-cursor'
|
||||
import { socialAuth } from './social-auth'
|
||||
|
||||
const blocks = [...clients, ...passwordBasedAuth, ...socialAuth]
|
||||
const blocks = [...clients, ...passwordBasedAuth, ...socialAuth, ...dropzone, ...realtimeCursor]
|
||||
|
||||
export { blocks }
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type RegistryItem } from 'shadcn/schema'
|
||||
|
||||
import nuxtjs from './default/realtime-cursor/nuxtjs/registry-item.json' with { type: 'json' }
|
||||
import vue from './default/realtime-cursor/vue/registry-item.json' with { type: 'json' }
|
||||
|
||||
export const realtimeCursor = [nuxtjs, vue] as RegistryItem[]
|
||||
@@ -1,28 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import { ChevronRight, HelpCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import * as React from 'react'
|
||||
import { useContext } from 'react'
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Tooltip as RechartsTooltip,
|
||||
TooltipProps as RechartsTooltipProps,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardTitle,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
Card,
|
||||
CardTitle,
|
||||
cn,
|
||||
CardContent,
|
||||
Skeleton,
|
||||
} from 'ui'
|
||||
import { ExternalLink, HelpCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
ResponsiveContainer,
|
||||
Tooltip as RechartsTooltip,
|
||||
TooltipProps as RechartsTooltipProps,
|
||||
} from 'recharts'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
interface MetricCardContextValue {
|
||||
isLoading?: boolean
|
||||
@@ -46,7 +46,11 @@ interface MetricCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
const MetricCard = React.forwardRef<HTMLDivElement, MetricCardProps>(
|
||||
({ isLoading = false, isDisabled = false, className, children, ...props }, ref) => {
|
||||
return (
|
||||
<Card ref={ref} className={cn(className)} {...props}>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={cn('group-hover:bg-surface-200 transition-colors', className)}
|
||||
{...props}
|
||||
>
|
||||
<MetricCardContext.Provider value={{ isLoading, isDisabled }}>
|
||||
{children}
|
||||
</MetricCardContext.Provider>
|
||||
@@ -59,10 +63,11 @@ MetricCard.displayName = 'MetricCard'
|
||||
interface MetricCardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
href?: string
|
||||
children: React.ReactNode
|
||||
linkTooltip?: string
|
||||
}
|
||||
|
||||
const MetricCardHeader = React.forwardRef<HTMLDivElement, MetricCardHeaderProps>(
|
||||
({ className, href, children, ...props }, ref) => {
|
||||
({ className, href, children, linkTooltip, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -73,14 +78,24 @@ const MetricCardHeader = React.forwardRef<HTMLDivElement, MetricCardHeaderProps>
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">{children}</div>
|
||||
{href && (
|
||||
<Button type="text" size="tiny" className="px-1 text-foreground-lighter" asChild>
|
||||
<Link href={href}>
|
||||
<ExternalLink aria-disabled={true} size={14} strokeWidth={1.5} />
|
||||
<span className="sr-only">More information</span>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{href ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
className="px-1 text-foreground-lighter group-hover:text-foreground absolute right-3 transition-colors"
|
||||
asChild
|
||||
>
|
||||
<Link href={href}>
|
||||
<ChevronRight aria-disabled={true} size={14} strokeWidth={1.5} />
|
||||
<span className="sr-only">More information</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{linkTooltip ? <TooltipContent>{linkTooltip}</TooltipContent> : null}
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -132,7 +147,7 @@ const MetricCardLabel = React.forwardRef<HTMLDivElement, MetricCardLabelProps>(
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle size={14} strokeWidth={1.5} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
<TooltipContent className="max-w-xs">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CardTitle>
|
||||
|
||||
Generated
+46
@@ -1876,6 +1876,9 @@ importers:
|
||||
'@supabase/supabase-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.93.2
|
||||
'@vueuse/core':
|
||||
specifier: ^14.1.0
|
||||
version: 14.1.0(vue@3.5.21(typescript@5.9.2))
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -1885,6 +1888,9 @@ importers:
|
||||
h3:
|
||||
specifier: ^1.15.5
|
||||
version: 1.15.5
|
||||
lucide-vue-next:
|
||||
specifier: ^0.562.0
|
||||
version: 0.562.0(vue@3.5.21(typescript@5.9.2))
|
||||
nuxt:
|
||||
specifier: ^4.0.3
|
||||
version: 4.1.2(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.1)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.21)(aws4fetch@1.0.20)(db0@0.3.2(@electric-sql/pglite@0.2.15)(drizzle-orm@0.44.2(@electric-sql/pglite@0.2.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(pg@8.16.3)))(drizzle-orm@0.44.2(@electric-sql/pglite@0.2.15)(@opentelemetry/api@1.9.0)(@types/pg@8.15.6)(pg@8.16.3))(encoding@0.1.13)(eslint@9.37.0(jiti@2.5.1)(supports-color@8.1.1))(ioredis@5.7.0(supports-color@8.1.1))(magicast@0.3.5)(rollup@4.50.2)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@5.9.2)(vite@7.1.11(@types/node@22.13.14)(jiti@2.5.1)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.1))(yaml@2.8.1)
|
||||
@@ -9751,6 +9757,9 @@ packages:
|
||||
'@types/uuid@9.0.8':
|
||||
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@types/webxr@0.5.20':
|
||||
resolution: {integrity: sha512-JGpU6qiIJQKUuVSKx1GtQnHJGxRjtfGIhzO2ilq43VZZS//f1h1Sgexbdk+Lq+7569a6EYhOWrUpIruR/1Enmg==}
|
||||
|
||||
@@ -10032,6 +10041,19 @@ packages:
|
||||
'@vue/shared@3.5.21':
|
||||
resolution: {integrity: sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==}
|
||||
|
||||
'@vueuse/core@14.1.0':
|
||||
resolution: {integrity: sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/metadata@14.1.0':
|
||||
resolution: {integrity: sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==}
|
||||
|
||||
'@vueuse/shared@14.1.0':
|
||||
resolution: {integrity: sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||
|
||||
@@ -14610,6 +14632,11 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
lucide-vue-next@0.562.0:
|
||||
resolution: {integrity: sha512-LN0BLGKMFulv0lnfK29r14DcngRUhIqdcaL0zXTt2o0oS9odlrjCGaU3/X9hIihOjjN8l8e+Y9G/famcNYaI7Q==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
lunr@2.3.9:
|
||||
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
|
||||
|
||||
@@ -29318,6 +29345,8 @@ snapshots:
|
||||
|
||||
'@types/uuid@9.0.8': {}
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@types/webxr@0.5.20': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
@@ -29763,6 +29792,19 @@ snapshots:
|
||||
|
||||
'@vue/shared@3.5.21': {}
|
||||
|
||||
'@vueuse/core@14.1.0(vue@3.5.21(typescript@5.9.2))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 14.1.0
|
||||
'@vueuse/shared': 14.1.0(vue@3.5.21(typescript@5.9.2))
|
||||
vue: 3.5.21(typescript@5.9.2)
|
||||
|
||||
'@vueuse/metadata@14.1.0': {}
|
||||
|
||||
'@vueuse/shared@14.1.0(vue@3.5.21(typescript@5.9.2))':
|
||||
dependencies:
|
||||
vue: 3.5.21(typescript@5.9.2)
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
dependencies:
|
||||
'@webassemblyjs/helper-numbers': 1.13.2
|
||||
@@ -34898,6 +34940,10 @@ snapshots:
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
lucide-vue-next@0.562.0(vue@3.5.21(typescript@5.9.2)):
|
||||
dependencies:
|
||||
vue: 3.5.21(typescript@5.9.2)
|
||||
|
||||
lunr@2.3.9: {}
|
||||
|
||||
luxon@3.5.0: {}
|
||||
|
||||
Reference in new issue
Block a user