)}
{!!hasNewNotifications && !hasCritical && !hasWarning && (
-
+
)}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx
index bc946ffbabe76..85b8d86d5ea0b 100644
--- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx
@@ -1,20 +1,49 @@
import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect } from 'react'
-import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
+
+import { AdvisorPanel } from 'components/ui/AdvisorPanel/AdvisorPanel'
import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant'
import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel'
+import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
+import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
+import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
+import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
export const SIDEBAR_KEYS = {
AI_ASSISTANT: 'ai-assistant',
EDITOR_PANEL: 'editor-panel',
+ ADVISOR_PANEL: 'advisor-panel',
} as const
+// LayoutSidebars are meant to be used within a project, but rendered within DefaultLayout
+// to prevent unnecessary registering / unregistering of sidebars with every route change
export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => {
+ const { data: project } = useSelectedProjectQuery()
+ const { data: org } = useSelectedOrganizationQuery()
+ const { mutate: sendEvent } = useSendEventMutation()
+
useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () =>
, {}, 'i')
useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () =>
, {}, 'e')
+ useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () =>
)
const router = useRouter()
- const { openSidebar } = useSidebarManagerSnapshot()
+ const { openSidebar, activeSidebar } = useSidebarManagerSnapshot()
+
+ useEffect(() => {
+ if (!!project && activeSidebar) {
+ // add event tracking
+ sendEvent({
+ action: 'sidebar_opened',
+ properties: {
+ sidebar: activeSidebar.id as (typeof SIDEBAR_KEYS)[keyof typeof SIDEBAR_KEYS],
+ },
+ groups: {
+ project: project?.ref ?? 'Unknown',
+ organization: org?.slug ?? 'Unknown',
+ },
+ })
+ }
+ }, [activeSidebar])
// Handle sidebar URL parameter
useEffect(() => {
diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
index 507121413df39..cb259ffca56f6 100644
--- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
@@ -21,7 +21,6 @@ import { useEditorType } from '../editors/EditorsLayout.hooks'
import BuildingState from './BuildingState'
import ConnectingState from './ConnectingState'
import { LayoutSidebar } from './LayoutSidebar'
-import { LayoutSidebarProvider } from './LayoutSidebar/LayoutSidebarProvider'
import { LoadingState } from './LoadingState'
import { ProjectPausedState } from './PausedState/ProjectPausedState'
import { PauseFailedState } from './PauseFailedState'
@@ -216,9 +215,7 @@ export const ProjectLayout = forwardRef
-
-
-
+
diff --git a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx
index 0785b981c5c45..4b0ce98a50ab0 100644
--- a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx
+++ b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx
@@ -86,9 +86,24 @@ const SignInLayout = ({
} | null>(null)
useEffect(() => {
- const randomQuote = tweets[Math.floor(Math.random() * tweets.length)]
-
- setQuote(randomQuote)
+ // Weighted random selection
+ // Calculate total weight (default weight is fallbackWeight for tweets without weight specified)
+ const fallbackWeight = 1
+ const totalWeight = tweets.reduce((sum, tweet) => sum + (tweet.weight ?? fallbackWeight), 0)
+
+ // Generate random number between 0 and totalWeight
+ const random = Math.random() * totalWeight
+
+ // Find the selected tweet based on cumulative weights
+ let accumulatedWeight = 0
+ for (const tweet of tweets) {
+ const weight = tweet.weight ?? fallbackWeight
+ accumulatedWeight += weight
+ if (random <= accumulatedWeight) {
+ setQuote(tweet)
+ break
+ }
+ }
}, [])
return (
diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
index b14e147105906..4f4bf8a8bfc72 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
@@ -61,7 +61,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
const disablePrompts = useFlag('disableAssistantPrompts')
const { snippets } = useSqlEditorV2StateSnapshot()
const snap = useAiAssistantStateSnapshot()
- const { closeSidebar, isSidebarOpen } = useSidebarManagerSnapshot()
+ const { closeSidebar, activeSidebar } = useSidebarManagerSnapshot()
const isPaidPlan = selectedOrganization?.plan?.id !== 'free'
@@ -435,11 +435,12 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
}, [snap.initialInput])
useEffect(() => {
- if (isSidebarOpen(SIDEBAR_KEYS.AI_ASSISTANT) && isInSQLEditor && !!snippetContent) {
+ const isOpen = activeSidebar?.id === SIDEBAR_KEYS.AI_ASSISTANT
+ if (isOpen && isInSQLEditor && !!snippetContent) {
snap.setSqlSnippets([{ label: 'Current Query', content: snippetContent }])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isSidebarOpen, isInSQLEditor, snippetContent])
+ }, [activeSidebar?.id, isInSQLEditor, snippetContent])
return (
+ source: 'lint'
+ original: Lint
+}
+
+const severityOptions = [
+ { label: 'Critical', value: 'critical' },
+ { label: 'Warning', value: 'warning' },
+ { label: 'Info', value: 'info' },
+]
+
+const severityOrder: Record = {
+ critical: 0,
+ warning: 1,
+ info: 2,
+}
+
+const severityLabels: Record = {
+ critical: 'Critical',
+ warning: 'Warning',
+ info: 'Info',
+}
+
+const severityBadgeVariants: Record = {
+ critical: 'destructive',
+ warning: 'warning',
+ info: 'default',
+}
+
+const severityColorClasses: Record = {
+ critical: 'text-destructive',
+ warning: 'text-warning',
+ info: 'text-foreground-light',
+}
+
+const tabIconMap: Record, React.ElementType> = {
+ security: Shield,
+ performance: Gauge,
+ messages: Inbox,
+}
+
+const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => {
+ switch (level) {
+ case 'ERROR':
+ return 'critical'
+ case 'WARN':
+ return 'warning'
+ default:
+ return 'info'
+ }
+}
+
+export const AdvisorPanel = () => {
+ const {
+ activeTab,
+ severityFilters,
+ selectedItemId,
+ setActiveTab,
+ setSeverityFilters,
+ clearSeverityFilters,
+ setSelectedItemId,
+ } = useAdvisorStateSnapshot()
+ const { data: project } = useSelectedProjectQuery()
+ const { activeSidebar, closeSidebar } = useSidebarManagerSnapshot()
+
+ const isSidebarOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL
+
+ const {
+ data: lintData,
+ isLoading: isLintsLoading,
+ isError: isLintsError,
+ } = useProjectLintsQuery(
+ { projectRef: project?.ref },
+ { enabled: isSidebarOpen && !!project?.ref }
+ )
+
+ const lintItems = useMemo(() => {
+ if (!lintData) return []
+
+ return lintData
+ .map((lint): AdvisorItem | null => {
+ const categories = lint.categories || []
+ const tab = categories.includes('SECURITY')
+ ? ('security' as const)
+ : categories.includes('PERFORMANCE')
+ ? ('performance' as const)
+ : undefined
+
+ if (!tab) return null
+
+ return {
+ id: lint.cache_key,
+ title: lint.detail,
+ severity: lintLevelToSeverity(lint.level),
+ createdAt: undefined,
+ tab,
+ source: 'lint' as const,
+ original: lint,
+ }
+ })
+ .filter((item): item is AdvisorItem => item !== null)
+ }, [lintData])
+
+ const combinedItems = useMemo(() => {
+ const all = [...lintItems]
+
+ return all.sort((a, b) => {
+ const severityDiff = severityOrder[a.severity] - severityOrder[b.severity]
+ if (severityDiff !== 0) return severityDiff
+
+ const createdDiff = (b.createdAt ?? 0) - (a.createdAt ?? 0)
+ if (createdDiff !== 0) return createdDiff
+
+ return a.title.localeCompare(b.title)
+ })
+ }, [lintItems])
+
+ const filteredItems = useMemo(() => {
+ return combinedItems.filter((item) => {
+ if (severityFilters.length > 0 && !severityFilters.includes(item.severity)) {
+ return false
+ }
+
+ if (activeTab === 'all') return true
+
+ return item.tab === activeTab
+ })
+ }, [combinedItems, severityFilters, activeTab])
+
+ const itemsFilteredByTabOnly = useMemo(() => {
+ return combinedItems.filter((item) => {
+ if (activeTab === 'all') return true
+ return item.tab === activeTab
+ })
+ }, [combinedItems, activeTab])
+
+ const hiddenItemsCount = itemsFilteredByTabOnly.length - filteredItems.length
+
+ const selectedItem = combinedItems.find((item) => item.id === selectedItemId)
+ const isDetailView = !!selectedItem
+
+ const isLoading = isLintsLoading
+ const isError = isLintsError
+
+ const handleTabChange = (tab: string) => {
+ setActiveTab(tab as AdvisorTab)
+ }
+
+ const handleBackToList = () => {
+ setSelectedItemId(undefined)
+ }
+
+ const handleClose = () => {
+ closeSidebar(SIDEBAR_KEYS.ADVISOR_PANEL)
+ }
+
+ return (
+
+ {isDetailView ? (
+ <>
+
+
}
+ onClick={handleBackToList}
+ tooltip={{ content: { side: 'bottom', text: 'Back to list' } }}
+ />
+
+
+ {selectedItem?.title}
+
+ {selectedItem && (
+
+ {severityLabels[selectedItem.severity]}
+
+ )}
+
+
}
+ onClick={handleClose}
+ tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
+ />
+
+
+ {selectedItem ? (
+
+ ) : (
+
+
+ Select an advisor item to view more details.
+
+
+ )}
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+ All
+
+
+ Security
+
+
+ Performance
+
+
+
+
+ setSeverityFilters(values as AdvisorSeverity[])}
+ />
+ }
+ onClick={handleClose}
+ tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
+ />
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : isError ? (
+
+
+
Error loading advisories
+
Please try again later.
+
+ ) : filteredItems.length === 0 ? (
+
0}
+ onClearFilters={clearSeverityFilters}
+ />
+ ) : (
+ <>
+
+ {filteredItems.map((item) => {
+ const SeverityIcon = tabIconMap[item.tab]
+ const severityClass = severityColorClasses[item.severity]
+ return (
+
+
+
+ )
+ })}
+
+ {severityFilters.length > 0 && hiddenItemsCount > 0 && (
+
+
+
+ )}
+ >
+ )}
+
+ >
+ )}
+
+ )
+}
+
+interface AdvisorDetailProps {
+ item: AdvisorItem
+ projectRef: string
+}
+
+const AdvisorDetail = ({ item, projectRef }: AdvisorDetailProps) => {
+ if (item.source === 'lint') {
+ const lint = item.original as Lint
+ return (
+
+
+
+ )
+ }
+}
diff --git a/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx b/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx
new file mode 100644
index 0000000000000..3096b0b19a9c1
--- /dev/null
+++ b/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx
@@ -0,0 +1,56 @@
+import { TextSearch } from 'lucide-react'
+import { Button } from 'ui'
+import { AdvisorTab } from 'state/advisor-state'
+
+interface EmptyAdvisorProps {
+ activeTab: AdvisorTab
+ hasFilters: boolean
+ onClearFilters: () => void
+}
+
+export const EmptyAdvisor = ({ activeTab, hasFilters, onClearFilters }: EmptyAdvisorProps) => {
+ const getHeading = () => {
+ if (hasFilters) return 'No items found'
+
+ switch (activeTab) {
+ case 'security':
+ return 'No security issues detected'
+ case 'performance':
+ return 'No performance issues detected'
+ case 'messages':
+ return 'No messages'
+ default:
+ return 'No issues detected'
+ }
+ }
+
+ const getMessage = () => {
+ if (hasFilters) return 'No advisor items match your current filters'
+
+ switch (activeTab) {
+ case 'security':
+ return 'Congrats! There are no security issues detected for this project'
+ case 'performance':
+ return 'Congrats! There are no performance issues detected for this project'
+ case 'messages':
+ return 'There are no messages for this project'
+ default:
+ return 'Congrats! There are no issues detected for this project'
+ }
+ }
+
+ return (
+
+
+
+
{getHeading()}
+
{getMessage()}
+
+ {hasFilters && (
+
+ )}
+
+ )
+}
diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx
index ab61c00f67678..ccffbffc34959 100644
--- a/apps/studio/components/ui/DatabaseSelector.tsx
+++ b/apps/studio/components/ui/DatabaseSelector.tsx
@@ -38,6 +38,7 @@ interface DatabaseSelectorProps {
onSelectId?: (id: string) => void // Optional callback
onCreateReplicaClick?: () => void
portal?: boolean
+ className?: string
}
const DatabaseSelector = ({
@@ -48,6 +49,7 @@ const DatabaseSelector = ({
buttonProps,
onCreateReplicaClick = noop,
portal = true,
+ className,
}: DatabaseSelectorProps) => {
const router = useRouter()
const { ref: projectRef } = useParams()
@@ -79,7 +81,7 @@ const DatabaseSelector = ({
return (
-
+
Source
diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
index e18c0b3ebd9de..ea19b47c2da74 100644
--- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
+++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
@@ -147,7 +147,7 @@ export const EditorPanel = () => {
return (
-
{label}
+
{label}
{templates.length > 0 && (
diff --git a/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.utils.ts b/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.utils.ts
new file mode 100644
index 0000000000000..5e2403521f575
--- /dev/null
+++ b/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.utils.ts
@@ -0,0 +1,49 @@
+export const isBinaryFile = (fileName: string): boolean => {
+ const extension = fileName.split('.').pop()?.toLowerCase()
+ const binaryExtensions = [
+ 'wasm',
+ 'jpg',
+ 'jpeg',
+ 'png',
+ 'gif',
+ 'bmp',
+ 'ico',
+ 'svg',
+ 'mp3',
+ 'mp4',
+ 'avi',
+ 'mov',
+ 'zip',
+ 'rar',
+ '7z',
+ 'tar',
+ 'gz',
+ 'bz2',
+ 'pdf',
+ ]
+ return binaryExtensions.includes(extension || '')
+}
+
+export const getLanguageFromFileName = (fileName: string): string => {
+ const extension = fileName.split('.').pop()?.toLowerCase()
+ switch (extension) {
+ case 'ts':
+ case 'tsx':
+ return 'typescript'
+ case 'js':
+ case 'jsx':
+ return 'javascript'
+ case 'json':
+ return 'json'
+ case 'html':
+ return 'html'
+ case 'css':
+ return 'css'
+ case 'md':
+ return 'markdown'
+ case 'csv':
+ return 'csv'
+ default:
+ return 'plaintext' // Default to plaintext
+ }
+}
diff --git a/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.tsx b/apps/studio/components/ui/FileExplorerAndEditor/index.tsx
similarity index 58%
rename from apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.tsx
rename to apps/studio/components/ui/FileExplorerAndEditor/index.tsx
index f425a22b38eec..f1988cff2aa7d 100644
--- a/apps/studio/components/ui/FileExplorerAndEditor/FileExplorerAndEditor.tsx
+++ b/apps/studio/components/ui/FileExplorerAndEditor/index.tsx
@@ -1,3 +1,4 @@
+import { AnimatePresence, motion } from 'framer-motion'
import { Edit, File, Plus, Trash } from 'lucide-react'
import { useEffect, useState } from 'react'
@@ -13,6 +14,7 @@ import {
TreeView,
TreeViewItem,
} from 'ui'
+import { getLanguageFromFileName, isBinaryFile } from './FileExplorerAndEditor.utils'
interface FileData {
id: number
@@ -32,35 +34,16 @@ interface FileExplorerAndEditorProps {
}
}
-const getLanguageFromFileName = (fileName: string): string => {
- const extension = fileName.split('.').pop()?.toLowerCase()
- switch (extension) {
- case 'ts':
- case 'tsx':
- return 'typescript'
- case 'js':
- case 'jsx':
- return 'javascript'
- case 'json':
- return 'json'
- case 'html':
- return 'html'
- case 'css':
- return 'css'
- case 'md':
- return 'markdown'
- default:
- return 'typescript' // Default to typescript
- }
-}
+const denoJsonDefaultContent = JSON.stringify({ imports: {} }, null, '\t')
-const FileExplorerAndEditor = ({
+export const FileExplorerAndEditor = ({
files,
onFilesChange,
aiEndpoint,
aiMetadata,
}: FileExplorerAndEditorProps) => {
const selectedFile = files.find((f) => f.selected) ?? files[0]
+ const [isDragOver, setIsDragOver] = useState(false)
const [treeData, setTreeData] = useState({
name: '',
@@ -95,9 +78,55 @@ const FileExplorerAndEditor = ({
])
}
+ const addDroppedFiles = async (droppedFiles: FileList) => {
+ const newFiles: FileData[] = []
+ const updatedFiles = files.map((f) => ({ ...f, selected: false }))
+
+ for (let i = 0; i < droppedFiles.length; i++) {
+ const file = droppedFiles[i]
+ const newId = Math.max(0, ...files.map((f) => f.id), ...newFiles.map((f) => f.id)) + 1
+
+ try {
+ let content: string
+ if (isBinaryFile(file.name)) {
+ // For binary files, read as ArrayBuffer and convert to base64 or keep as binary data
+ const arrayBuffer = await file.arrayBuffer()
+ const bytes = new Uint8Array(arrayBuffer)
+ content = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
+ } else {
+ content = await file.text()
+ }
+
+ newFiles.push({
+ id: newId,
+ name: file.name,
+ content,
+ selected: i === droppedFiles.length - 1, // Select the last dropped file
+ })
+ } catch (error) {
+ console.error(`Failed to read file ${file.name}:`, error)
+ }
+ }
+
+ if (newFiles.length > 0) {
+ onFilesChange([...updatedFiles, ...newFiles])
+ }
+ }
+
const handleFileNameChange = (id: number, newName: string) => {
if (!newName.trim()) return // Don't allow empty names
- const updatedFiles = files.map((file) => (file.id === id ? { ...file, name: newName } : file))
+ const updatedFiles = files.map((file) =>
+ file.id === id
+ ? {
+ ...file,
+ name: newName,
+ content:
+ newName === 'deno.json' && file.content === ''
+ ? denoJsonDefaultContent
+ : file.content,
+ }
+ : file
+ )
onFilesChange(updatedFiles)
}
@@ -145,6 +174,26 @@ const FileExplorerAndEditor = ({
setTreeData(updatedTreeData)
}
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault()
+ setIsDragOver(true)
+ }
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault()
+ setIsDragOver(false)
+ }
+
+ const handleDrop = async (e: React.DragEvent) => {
+ e.preventDefault()
+ setIsDragOver(false)
+
+ const droppedFiles = e.dataTransfer.files
+ if (droppedFiles.length > 0) {
+ await addDroppedFiles(droppedFiles)
+ }
+ }
+
// Update treeData when files change
useEffect(() => {
setTreeData({
@@ -161,7 +210,27 @@ const FileExplorerAndEditor = ({
}, [files])
return (
-
+
+
+ {isDragOver && (
+
+
+
Drop files here to add them
+
+
+ )}
+
@@ -197,13 +266,17 @@ const FileExplorerAndEditor = ({
icon={}
isEditing={Boolean(element.metadata?.isEditing)}
onEditSubmit={(value) => {
- if (originalId !== null) handleFileNameChange(originalId, value)
+ if (originalId !== null) {
+ handleFileNameChange(originalId, value)
+ }
}}
onClick={() => {
if (originalId !== null) handleFileSelect(originalId)
}}
onDoubleClick={() => {
- if (originalId !== null) handleStartRename(originalId)
+ if (originalId !== null) {
+ handleStartRename(originalId)
+ }
}}
/>
@@ -226,7 +299,9 @@ const FileExplorerAndEditor = ({
{
- if (originalId !== null) handleFileDelete(originalId)
+ if (originalId !== null) {
+ handleFileDelete(originalId)
+ }
}}
onFocusCapture={(e) => e.stopPropagation()}
>
@@ -243,26 +318,36 @@ const FileExplorerAndEditor = ({
-
+ {selectedFile && isBinaryFile(selectedFile.name) ? (
+
+
+
Cannot Edit Selected File
+
+ Binary files like .{selectedFile.name.split('.').pop()} cannot be edited in the text
+ editor
+
+
+
+ ) : (
+
+ )}
)
}
-
-export default FileExplorerAndEditor
diff --git a/apps/studio/components/ui/FilterPopover.tsx b/apps/studio/components/ui/FilterPopover.tsx
index e8260e71fafb1..4ae0b2365a5e0 100644
--- a/apps/studio/components/ui/FilterPopover.tsx
+++ b/apps/studio/components/ui/FilterPopover.tsx
@@ -89,7 +89,7 @@ export const FilterPopover = >({
})
useEffect(() => {
- if (!open && activeOptions.length > 0) setSelectedOptions(activeOptions)
+ if (!open) setSelectedOptions(activeOptions)
if (!open) setSearch('')
}, [open, activeOptions])
diff --git a/apps/studio/data/edge-functions/edge-function-body-query.ts b/apps/studio/data/edge-functions/edge-function-body-query.ts
index 2f9b921ad2e14..7fe5947f63393 100644
--- a/apps/studio/data/edge-functions/edge-function-body-query.ts
+++ b/apps/studio/data/edge-functions/edge-function-body-query.ts
@@ -1,6 +1,7 @@
+import { getMultipartBoundary, parseMultipartStream } from '@mjackson/multipart-parser'
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
-import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
-import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
+import { get, handleError } from 'data/fetchers'
+import { IS_PLATFORM } from 'lib/constants'
import { ResponseError } from 'types'
import { edgeFunctionsKeys } from './keys'
@@ -15,10 +16,29 @@ export type EdgeFunctionFile = {
}
export type EdgeFunctionBodyResponse = {
- version: number
files: EdgeFunctionFile[]
}
+async function streamToString(stream: ReadableStream) {
+ const reader = stream.getReader()
+ const decoder = new TextDecoder()
+ let result = ''
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ result += decoder.decode(value, { stream: true })
+ }
+ // Final decode to handle any remaining bytes
+ result += decoder.decode()
+ return result
+ } catch (error) {
+ console.error('Error reading stream:', error)
+ throw error
+ }
+}
+
export async function getEdgeFunctionBody(
{ projectRef, slug }: EdgeFunctionBodyVariables,
signal?: AbortSignal
@@ -26,41 +46,31 @@ export async function getEdgeFunctionBody(
if (!projectRef) throw new Error('projectRef is required')
if (!slug) throw new Error('slug is required')
- try {
- // Get authorization headers
- const headers = await constructHeaders({
- 'Content-Type': 'application/json',
- })
+ const { data, response, error } = await get('/v1/projects/{ref}/functions/{function_slug}/body', {
+ params: { path: { ref: projectRef, function_slug: slug } },
+ headers: { Accept: 'multipart/form-data' },
+ parseAs: 'stream',
+ signal,
+ })
- // Send to our API for processing (the API will handle the fetch from v1 endpoint)
- const parseResponse = await fetchHandler(`${BASE_PATH}/api/edge-functions/body`, {
- method: 'POST',
- body: JSON.stringify({ projectRef, slug }),
- headers,
- credentials: 'include',
- signal,
- })
+ if (error) handleError(error)
- if (!parseResponse.ok) {
- const { error } = await parseResponse.json()
- handleError(
- typeof error === 'object'
- ? error
- : typeof error === 'string'
- ? { message: error }
- : { message: 'Unknown error' }
- )
- }
+ const contentTypeHeader = response.headers.get('content-type') ?? ''
+ const boundary = getMultipartBoundary(contentTypeHeader)
+ const files = []
- const response = (await parseResponse.json()) as EdgeFunctionBodyResponse
- return response
- } catch (error) {
- handleError(error)
- return {
- version: 0,
- files: [],
- } as EdgeFunctionBodyResponse
+ if (!data || !boundary) return { files: [] }
+
+ for await (let part of parseMultipartStream(data, { boundary })) {
+ if (part.isFile) {
+ files.push({
+ name: part.filename,
+ content: part.text,
+ })
+ }
}
+
+ return { files: files as EdgeFunctionFile[] }
}
export type EdgeFunctionBodyData = Awaited>
diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts
index 6b2969b8217fe..aa8d66e428c96 100644
--- a/apps/studio/data/fetchers.ts
+++ b/apps/studio/data/fetchers.ts
@@ -24,7 +24,7 @@ export const fetchHandler: typeof fetch = async (input, init) => {
}
}
-const client = createClient({
+export const client = createClient({
fetch: fetchHandler,
// [Joshen] Just FYI, the replace is temporary until we update env vars API_URL to remove /platform or /v1 - should just be the base URL
baseUrl: API_URL?.replace('/platform', ''),
diff --git a/apps/studio/lib/eszip-parser.test.ts b/apps/studio/lib/eszip-parser.test.ts
deleted file mode 100644
index ec38531a19d73..0000000000000
--- a/apps/studio/lib/eszip-parser.test.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { Parser } from '@deno/eszip'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { parseEszip } from './eszip-parser'
-
-vi.mock('@deno/eszip', () => ({
- Parser: {
- createInstance: vi.fn(),
- },
-}))
-
-vi.stubGlobal(
- 'File',
- class MockFile {
- name: string
- content: string
-
- constructor(content: string[], name: string) {
- this.name = name
- this.content = content[0]
- }
-
- async text() {
- return this.content
- }
- }
-)
-
-vi.stubGlobal(
- 'URL',
- class MockURL {
- pathname: string
-
- constructor(url: string) {
- this.pathname = url
- }
- }
-)
-
-describe('eszip-parser', () => {
- const mockParser = {
- parseBytes: vi.fn(),
- load: vi.fn(),
- getModuleSource: vi.fn(),
- }
-
- beforeEach(() => {
- vi.clearAllMocks()
- ;(Parser.createInstance as any).mockResolvedValue(mockParser)
- })
-
- afterEach(() => {
- vi.resetAllMocks()
- })
-
- describe('parseEszip', () => {
- it('should successfully parse and extract files from eszip', async () => {
- const mockBytes = new Uint8Array([1, 2, 3])
- const mockSpecifiers = ['file1.ts', 'file2.ts']
- const mockModuleSource1 = 'export const hello = "world"'
- const mockModuleSource2 = 'export const foo = "bar"'
-
- mockParser.parseBytes.mockResolvedValue(mockSpecifiers)
- mockParser.load.mockResolvedValue(undefined)
- mockParser.getModuleSource
- .mockResolvedValueOnce(null)
- .mockResolvedValueOnce(mockModuleSource1)
- .mockResolvedValueOnce(mockModuleSource2)
-
- const result = await parseEszip(mockBytes)
-
- expect(Parser.createInstance).toHaveBeenCalledTimes(1)
- expect(mockParser.parseBytes).toHaveBeenCalledWith(mockBytes)
- expect(mockParser.load).toHaveBeenCalled()
- expect(result.version).toEqual(0)
- expect(result.files).toHaveLength(2)
- expect(result.files[0]).toEqual({
- name: 'file1.ts',
- content: mockModuleSource1,
- })
- expect(result.files[1]).toEqual({
- name: 'file2.ts',
- content: mockModuleSource2,
- })
- })
-
- it('should handle parseBytes failure', async () => {
- mockParser.parseBytes.mockRejectedValue(new Error('Parse error'))
- await expect(parseEszip(new Uint8Array())).rejects.toThrow('Parse error')
- })
-
- it('should handle load failure', async () => {
- mockParser.parseBytes.mockResolvedValue(['file1.ts'])
- mockParser.load.mockRejectedValue(new Error('Load error'))
-
- await expect(parseEszip(new Uint8Array())).rejects.toThrow('Load error')
- })
-
- it('should filter out unwanted specifiers', async () => {
- const mockBytes = new Uint8Array([1, 2, 3])
- const mockSpecifiers = [
- 'file1.ts',
- 'npm:package',
- 'https://example.com/file.ts',
- 'file2.ts',
- '---internal',
- 'jsr:package',
- ]
- const mockModuleSource = 'export const test = "test"'
-
- mockParser.parseBytes.mockResolvedValue(mockSpecifiers)
- mockParser.load.mockResolvedValue(undefined)
- mockParser.getModuleSource.mockResolvedValue(mockModuleSource)
-
- const result = await parseEszip(mockBytes)
- // Only file1.ts and file2.ts should be included
- expect(result.version).toEqual(0)
- expect(result.files).toHaveLength(2)
- expect(result.files[0].name).toBe('file1.ts')
- expect(result.files[1].name).toBe('file2.ts')
- })
- })
-})
diff --git a/apps/studio/lib/eszip-parser.ts b/apps/studio/lib/eszip-parser.ts
deleted file mode 100644
index 7aa5428e7478e..0000000000000
--- a/apps/studio/lib/eszip-parser.ts
+++ /dev/null
@@ -1,144 +0,0 @@
-import { Parser } from '@deno/eszip'
-import path from 'path'
-
-function url2path(url: string) {
- try {
- // Parse the URL
- return new URL(url).pathname
- } catch (error) {
- // If URL parsing fails, fallback to extracting just the filename
- console.warn('Failed to parse URL:', url)
- try {
- // Try to extract just the filename part
- const parts = url.split('/').filter(Boolean)
- if (parts.length > 0) {
- return parts[parts.length - 1] // Return just the filename
- }
- } catch (e) {
- // Last resort: use the original path joining
- console.error('Failed to extract filename:', e)
- }
- return path.join(...new URL(url).pathname.split('/').filter(Boolean))
- }
-}
-
-// Initialize parser outside of request handler
-let parserPromise: Promise | null = null
-
-async function getParser() {
- if (!parserPromise) {
- parserPromise = Parser.createInstance().catch((err) => {
- console.error('Failed to create parser instance:', err)
- parserPromise = null
- throw err
- })
- }
- return parserPromise
-}
-
-export async function parseEszip(bytes: Uint8Array) {
- try {
- const parser = await getParser()
- if (!parser) {
- throw new Error('Failed to initialize parser')
- }
-
- // Parse bytes in a try-catch block
- let specifiers: string[] = []
- try {
- specifiers = await parser.parseBytes(bytes)
- } catch (parseError) {
- console.error('Error parsing bytes:', parseError)
- // Reset parser on parse error
- parserPromise = null
- throw parseError
- }
-
- // Load in a separate try-catch
- try {
- await parser.load()
- } catch (loadError) {
- console.error('Error loading parser:', loadError)
- parserPromise = null
- throw loadError
- }
-
- // Extract version
- let version = parseInt(await parser.getModuleSource('---SUPABASE-ESZIP-VERSION-ESZIP---'))
- if (isNaN(version)) {
- version = 0
- }
-
- // Extract files from the eszip
- const files = await extractEszip(parser, specifiers, version >= 2)
-
- // Convert files to the expected format
- const responseFiles = await Promise.all(
- files.map(async (file) => {
- const content = await file.text()
- return {
- name: file.name,
- content: content,
- }
- })
- )
-
- return {
- version,
- files: responseFiles,
- }
- } catch (error) {
- console.error('Error in parseEszip:', error)
- throw error
- }
-}
-
-async function extractEszip(parser: any, specifiers: string[], isDeno2: boolean) {
- const files = []
-
- // First, filter out the specifiers we want to keep
- const filteredSpecifiers = specifiers.filter((specifier) => {
- const shouldSkip =
- specifier.startsWith('---') ||
- specifier.startsWith('npm:') ||
- specifier.startsWith('static:') ||
- specifier.startsWith('vfs:') ||
- specifier.startsWith('https:') ||
- specifier.startsWith('jsr:')
-
- if (shouldSkip) {
- console.log('Skipping specifier:', specifier)
- } else {
- console.log('Keeping specifier:', specifier)
- }
-
- return !shouldSkip
- })
-
- console.log('Filtered specifiers count:', filteredSpecifiers.length)
- console.log('Filtered specifiers:', JSON.stringify(filteredSpecifiers))
-
- // Then process each one
- for (const specifier of filteredSpecifiers) {
- try {
- // Try to get the module source
- const moduleSource = await parser.getModuleSource(specifier)
- let qualifiedSpecifier = specifier
-
- // Get the file path
- if (isDeno2 && !specifier.startsWith('file://')) {
- qualifiedSpecifier = `file://${specifier}`
- }
- const filePath = url2path(qualifiedSpecifier)
-
- // Create a file object
- const file = new File([moduleSource], filePath)
-
- files.push(file)
- } catch (error) {
- console.error('Error processing specifier:', specifier, error)
- }
- }
-
- return files
-}
diff --git a/apps/studio/lib/gotrue.ts b/apps/studio/lib/gotrue.ts
index ab350710682dd..b51fdd29e7111 100644
--- a/apps/studio/lib/gotrue.ts
+++ b/apps/studio/lib/gotrue.ts
@@ -1,11 +1,6 @@
-import * as Sentry from '@sentry/nextjs'
import type { JwtPayload } from '@supabase/supabase-js'
import { getAccessToken, type User } from 'common/auth'
-import { gotrueClient, setCaptureException } from 'common/gotrue'
-
-setCaptureException((e: any) => {
- Sentry.captureException(e)
-})
+import { gotrueClient } from 'common/gotrue'
export const auth = gotrueClient
export { getAccessToken }
diff --git a/apps/studio/package.json b/apps/studio/package.json
index 2518fbf3c5f37..35535056282f3 100644
--- a/apps/studio/package.json
+++ b/apps/studio/package.json
@@ -32,7 +32,6 @@
"@ai-sdk/react": "2.0.45",
"@aws-sdk/credential-providers": "^3.804.0",
"@dagrejs/dagre": "^1.0.4",
- "@deno/eszip": "0.83.0",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^8.0.0",
@@ -45,6 +44,7 @@
"@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.1.3",
"@hookform/resolvers": "^3.1.1",
+ "@mjackson/multipart-parser": "^0.10.1",
"@modelcontextprotocol/sdk": "^1.18.0",
"@monaco-editor/react": "^4.6.0",
"@next/bundle-analyzer": "15.3.1",
diff --git a/apps/studio/pages/api/edge-functions/body.ts b/apps/studio/pages/api/edge-functions/body.ts
deleted file mode 100644
index ed220a07144ed..0000000000000
--- a/apps/studio/pages/api/edge-functions/body.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { API_URL } from 'lib/constants'
-import { parseEszip } from 'lib/eszip-parser'
-import { NextApiRequest, NextApiResponse } from 'next'
-
-export default async function handler(req: NextApiRequest, res: NextApiResponse) {
- const { method } = req
-
- switch (method) {
- case 'POST':
- return handlePost(req, res)
- default:
- return new Response(
- JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }),
- {
- status: 405,
- headers: { 'Content-Type': 'application/json', Allow: 'POST' },
- }
- )
- }
-}
-
-async function handlePost(req: NextApiRequest, res: NextApiResponse) {
- try {
- const { projectRef, slug } = req.body || {}
-
- if (!projectRef) {
- return res.status(400).json({ error: 'projectRef is required' })
- }
- if (!slug) {
- return res.status(400).json({ error: 'slug is required' })
- }
-
- // Get authorization token from the request
- const authToken = req.headers.authorization
-
- if (!authToken) {
- return res.status(401).json({ error: 'No authorization token was found' })
- }
-
- // Fetch the eszip data
- const headers = new Headers()
- headers.set('Accept', 'application/octet-stream')
- headers.set('Authorization', typeof authToken === 'string' ? authToken : authToken[0])
-
- // Forward other important headers
- if (req.headers.cookie) {
- headers.set('Cookie', req.headers.cookie)
- }
-
- const baseUrl = API_URL?.replace('/platform', '')
- const url = `${baseUrl}/v1/projects/${projectRef}/functions/${slug}/body`
-
- const response = await fetch(url, {
- method: 'GET',
- headers,
- credentials: 'include',
- referrerPolicy: 'no-referrer-when-downgrade',
- })
-
- if (!response.ok) {
- const error = await response.json()
- return res.status(response.status).json(error)
- }
-
- // Verify content type is binary/eszip
- const contentType = response.headers.get('content-type')
- if (!contentType || !contentType.includes('application/octet-stream')) {
- return res.status(400).json({
- error:
- 'Invalid response: Expected eszip file but received ' + (contentType || 'unknown format'),
- })
- }
-
- // Get the eszip data as ArrayBuffer
- const arrayBuffer = await response.arrayBuffer()
-
- if (arrayBuffer.byteLength === 0) {
- return res.status(400).json({ error: 'Invalid eszip: File is empty' })
- }
-
- const uint8Array = new Uint8Array(arrayBuffer)
-
- // Parse the eszip file using our utility
- const parsed = await parseEszip(uint8Array)
-
- return res.status(200).json(parsed)
- } catch (error) {
- console.error('Error processing edge function body:', error)
- return res.status(500).json({ error: 'Internal server error' })
- }
-}
diff --git a/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx b/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx
index f3097485c6b12..30951b5af5b7a 100644
--- a/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx
+++ b/apps/studio/pages/project/[ref]/auth/templates/[templateId].tsx
@@ -1,9 +1,12 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { useEffect } from 'react'
import { useIsSecurityNotificationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { TEMPLATES_SCHEMAS } from 'components/interfaces/Auth/AuthTemplatesValidation'
import { slugifyTitle } from 'components/interfaces/Auth/EmailTemplates/EmailTemplates.utils'
-import TemplateEditor from 'components/interfaces/Auth/EmailTemplates/TemplateEditor'
+import { TemplateEditor } from 'components/interfaces/Auth/EmailTemplates/TemplateEditor'
import AuthLayout from 'components/layouts/AuthLayout/AuthLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
@@ -12,9 +15,6 @@ import { DocsButton } from 'components/ui/DocsButton'
import NoPermission from 'components/ui/NoPermission'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { DOCS_URL } from 'lib/constants'
-import Link from 'next/link'
-import { useRouter } from 'next/router'
-import { useEffect } from 'react'
import type { NextPageWithLayout } from 'types'
import { Button, Card } from 'ui'
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
@@ -33,6 +33,16 @@ const RedirectToTemplates = () => {
'custom_config_gotrue'
)
+ // Find template whose slug matches the URL slug
+ const template =
+ templateId && typeof templateId === 'string'
+ ? TEMPLATES_SCHEMAS.find((template) => slugifyTitle(template.title) === templateId)
+ : null
+
+ // Convert templateId slug to one lowercase word to match docs anchor tag
+ const templateIdForDocs =
+ typeof templateId === 'string' ? templateId.replace(/-/g, '').toLowerCase() : ''
+
useEffect(() => {
if (isPermissionsLoaded && !isSecurityNotificationsEnabled) {
router.replace(`/project/${ref}/auth/templates/`)
@@ -43,18 +53,12 @@ const RedirectToTemplates = () => {
return
}
- if (!isSecurityNotificationsEnabled) {
+ if (!isSecurityNotificationsEnabled || !templateId) {
return null
}
- // Find template whose slug matches the URL slug
- const template =
- templateId && typeof templateId === 'string'
- ? TEMPLATES_SCHEMAS.find((template) => slugifyTitle(template.title) === templateId)
- : null
-
// Show error if templateId is invalid or template is not found
- if (!template || !templateId || typeof templateId !== 'string') {
+ if (!template) {
return (
{
)
}
- // Convert templateId slug to one lowercase word to match docs anchor tag
- const templateIdForDocs = templateId.replace(/-/g, '').toLowerCase()
-
return (
{
const { can: canDeployFunction } = useAsyncCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
- const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
+ const { data: selectedFunction } = useEdgeFunctionQuery({
+ projectRef: ref,
+ slug: functionSlug,
+ })
const {
data: functionBody,
isLoading: isLoadingFiles,
@@ -110,6 +113,9 @@ const CodePage = () => {
import_map_path: files.some(({ name }) => name === newImportMapPath)
? newImportMapPath
: fallbackImportMapPath(),
+ static_patterns: files
+ .filter(({ name }) => !name.match(/\.(js|ts|jsx|tsx|json|wasm)$/i))
+ .map(({ name }) => name),
},
files: files.map(({ name, content }) => ({ name, content })),
})
@@ -120,31 +126,23 @@ const CodePage = () => {
}
}
- function getBasePath(
- entrypoint: string | undefined,
- fileNames: string[],
- version: number
- ): string {
+ function getBasePath(entrypoint: string | undefined, fileNames: string[]): string {
if (!entrypoint) {
return '/'
}
- let qualifiedEntrypoint = entrypoint
+ let candidate = fileNames.find((name) => entrypoint.endsWith(name))
- if (version >= 2) {
- const candidate = fileNames.find((name) => entrypoint.endsWith(name))
- if (candidate) {
- qualifiedEntrypoint = `file://${candidate}`
- } else {
- qualifiedEntrypoint = entrypoint
+ if (candidate) {
+ return dirname(candidate)
+ } else {
+ try {
+ return dirname(new URL(entrypoint).pathname)
+ } catch (e) {
+ console.error('Failed to parse entrypoint', entrypoint)
+ return '/'
}
}
- try {
- return dirname(new URL(qualifiedEntrypoint).pathname)
- } catch (e) {
- console.error('Failed to parse entrypoint', qualifiedEntrypoint)
- return '/'
- }
}
const handleDeployClick = () => {
@@ -152,14 +150,20 @@ const CodePage = () => {
setShowDeployWarning(true)
sendEvent({
action: 'edge_function_deploy_updates_button_clicked',
- groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
+ groups: {
+ project: ref ?? 'Unknown',
+ organization: org?.slug ?? 'Unknown',
+ },
})
}
const handleDeployConfirm = () => {
sendEvent({
action: 'edge_function_deploy_updates_confirm_clicked',
- groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
+ groups: {
+ project: ref ?? 'Unknown',
+ organization: org?.slug ?? 'Unknown',
+ },
})
onUpdate()
}
@@ -169,12 +173,9 @@ const CodePage = () => {
if (selectedFunction?.entrypoint_path && functionBody) {
const base_path = getBasePath(
selectedFunction?.entrypoint_path,
- functionBody.files.map((file) => file.name),
- functionBody.version
+ functionBody.files.map((file) => file.name)
)
const filesWithRelPath = functionBody.files
- // ignore empty files
- .filter((file: { name: string; content: string }) => !!file.content.length)
// set file paths relative to entrypoint
.map((file: { name: string; content: string }) => {
try {
@@ -185,7 +186,8 @@ const CodePage = () => {
return file
}
- file.name = relative(base_path, file.name)
+ // prepend "/" to turn relative paths to absolute
+ file.name = relative('/' + base_path, '/' + file.name)
return file
} catch (e) {
console.error(e)
diff --git a/apps/studio/pages/project/[ref]/functions/new.tsx b/apps/studio/pages/project/[ref]/functions/new.tsx
index b813c3babb6be..507a532cfe0ee 100644
--- a/apps/studio/pages/project/[ref]/functions/new.tsx
+++ b/apps/studio/pages/project/[ref]/functions/new.tsx
@@ -11,7 +11,8 @@ import { EDGE_FUNCTION_TEMPLATES } from 'components/interfaces/Functions/Functio
import DefaultLayout from 'components/layouts/DefaultLayout'
import EdgeFunctionsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
-import FileExplorerAndEditor from 'components/ui/FileExplorerAndEditor/FileExplorerAndEditor'
+import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
+import { FileExplorerAndEditor } from 'components/ui/FileExplorerAndEditor'
import { useEdgeFunctionDeployMutation } from 'data/edge-functions/edge-functions-deploy-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
@@ -19,6 +20,7 @@ import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { BASE_PATH } from 'lib/constants'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
+import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import {
AiIconAnimation,
Button,
@@ -42,8 +44,6 @@ import {
TooltipContent,
TooltipTrigger,
} from 'ui'
-import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
-import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
// Array of adjectives and nouns for random function name generation
const ADJECTIVES = [
diff --git a/apps/studio/state/advisor-state.ts b/apps/studio/state/advisor-state.ts
new file mode 100644
index 0000000000000..a5839330a32ec
--- /dev/null
+++ b/apps/studio/state/advisor-state.ts
@@ -0,0 +1,40 @@
+import { proxy, snapshot, useSnapshot } from 'valtio'
+
+export type AdvisorTab = 'all' | 'security' | 'performance' | 'messages'
+export type AdvisorSeverity = 'critical' | 'warning' | 'info'
+
+const initialState = {
+ activeTab: 'all' as AdvisorTab,
+ severityFilters: ['critical'] as AdvisorSeverity[],
+ selectedItemId: undefined as string | undefined,
+}
+
+export const advisorState = proxy({
+ ...initialState,
+ setActiveTab(tab: AdvisorTab) {
+ advisorState.activeTab = tab
+ },
+ setSeverityFilters(severities: AdvisorSeverity[]) {
+ advisorState.severityFilters = severities
+ },
+ clearSeverityFilters() {
+ advisorState.severityFilters = []
+ },
+ setSelectedItemId(id: string | undefined) {
+ advisorState.selectedItemId = id
+ },
+ focusItem({ id, tab }: { id: string; tab?: AdvisorTab }) {
+ if (tab) {
+ advisorState.activeTab = tab
+ }
+ advisorState.selectedItemId = id
+ },
+ reset() {
+ Object.assign(advisorState, initialState)
+ },
+})
+
+export const getAdvisorStateSnapshot = () => snapshot(advisorState)
+
+export const useAdvisorStateSnapshot = (options?: Parameters[1]) =>
+ useSnapshot(advisorState, options)
diff --git a/apps/studio/state/sidebar-manager-state.tsx b/apps/studio/state/sidebar-manager-state.tsx
index bb455845ec491..e0e48404090a4 100644
--- a/apps/studio/state/sidebar-manager-state.tsx
+++ b/apps/studio/state/sidebar-manager-state.tsx
@@ -151,12 +151,16 @@ export const useRegisterSidebar = (
)
useEffect(() => {
- const { registerSidebar, unregisterSidebar } = sidebarManagerState
+ const { registerSidebar, unregisterSidebar, sidebars } = sidebarManagerState
- registerSidebar(id, component, handlers)
+ if (!sidebars[id]) {
+ registerSidebar(id, component, handlers)
+ }
return () => {
- unregisterSidebar(id)
+ if (sidebars[id]) {
+ unregisterSidebar(id)
+ }
}
}, [id])
diff --git a/apps/studio/types/form.ts b/apps/studio/types/form.ts
index 8a4bf1380d6a1..0bd0bce02a156 100644
--- a/apps/studio/types/form.ts
+++ b/apps/studio/types/form.ts
@@ -32,5 +32,6 @@ export interface FormSchema {
title?: string
description?: string
}
+ emailTemplateType?: 'authentication' | 'security'
}
}
diff --git a/apps/www/components/Sections/TwitterSocialProof.tsx b/apps/www/components/Sections/TwitterSocialProof.tsx
index 1c436bdc959fb..615072fe1a0fc 100644
--- a/apps/www/components/Sections/TwitterSocialProof.tsx
+++ b/apps/www/components/Sections/TwitterSocialProof.tsx
@@ -1,22 +1,23 @@
+import { range } from 'lib/helpers'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { cn } from 'ui'
import { TweetCard } from 'ui-patterns/TweetCard'
-import { range } from 'lib/helpers'
-import tweets from 'shared-data/tweets'
import { useBreakpoint } from 'common'
import React from 'react'
+import { topTweets } from 'shared-data/tweets'
interface Props {
className?: string
}
+const tweetsData = topTweets
+
const TwitterSocialProof: React.FC = ({ className }) => {
const { basePath } = useRouter()
const isSm = useBreakpoint()
const isMd = useBreakpoint(1024)
- const tweetsData = tweets.slice(0, 18)
return (
<>
diff --git a/apps/www/data/home/content.tsx b/apps/www/data/home/content.tsx
index fcfc96dd66ea6..0e081811b880f 100644
--- a/apps/www/data/home/content.tsx
+++ b/apps/www/data/home/content.tsx
@@ -5,7 +5,7 @@ import { Button } from 'ui'
import ProductModules from '../ProductModules'
import MainProducts from 'data/MainProducts'
-import tweets from 'shared-data/tweets'
+import { topTweets } from 'shared-data/tweets'
import { IconDiscord } from 'ui'
export default () => {
@@ -188,7 +188,7 @@ export default () => {
),
- tweets: tweets.slice(0, 18),
+ tweets: topTweets,
},
}
}
diff --git a/apps/www/data/solutions/beginners.tsx b/apps/www/data/solutions/beginners.tsx
index e217af26e7255..cd9b89ee0304c 100644
--- a/apps/www/data/solutions/beginners.tsx
+++ b/apps/www/data/solutions/beginners.tsx
@@ -21,7 +21,7 @@ import {
import { useBreakpoint } from 'common'
import { useSendTelemetryEvent } from 'lib/telemetry'
-import { tweets } from 'shared-data'
+import { topTweets } from 'shared-data'
import { PRODUCT_SHORTNAMES } from 'shared-data/products'
const AuthVisual = dynamic(() => import('components/Products/AuthVisual'))
@@ -495,7 +495,7 @@ const data: () => {
),
- tweets: tweets.slice(0, 18),
+ tweets: topTweets,
},
platformStarterSection: {
id: 'platform-starter',
diff --git a/apps/www/public/images/twitter-profiles/2SQwtv8c_400x400.jpg b/apps/www/public/images/twitter-profiles/2SQwtv8c_400x400.jpg
new file mode 100644
index 0000000000000..8e86078de9d61
Binary files /dev/null and b/apps/www/public/images/twitter-profiles/2SQwtv8c_400x400.jpg differ
diff --git a/apps/www/public/images/twitter-profiles/UCBhUBZl_400x400.jpg b/apps/www/public/images/twitter-profiles/UCBhUBZl_400x400.jpg
deleted file mode 100644
index 86a1b942657c4..0000000000000
Binary files a/apps/www/public/images/twitter-profiles/UCBhUBZl_400x400.jpg and /dev/null differ
diff --git a/apps/www/public/images/twitter-profiles/Y1swF6ef_400x400.jpg b/apps/www/public/images/twitter-profiles/Y1swF6ef_400x400.jpg
new file mode 100644
index 0000000000000..9a9cdcd3f266c
Binary files /dev/null and b/apps/www/public/images/twitter-profiles/Y1swF6ef_400x400.jpg differ
diff --git a/apps/www/public/images/twitter-profiles/_iAaSUQf_400x400.jpg b/apps/www/public/images/twitter-profiles/_iAaSUQf_400x400.jpg
new file mode 100644
index 0000000000000..88c7f46728788
Binary files /dev/null and b/apps/www/public/images/twitter-profiles/_iAaSUQf_400x400.jpg differ
diff --git a/apps/www/public/images/twitter-profiles/k0aPYRHF_400x400.jpg b/apps/www/public/images/twitter-profiles/k0aPYRHF_400x400.jpg
new file mode 100644
index 0000000000000..4997a4c0584fd
Binary files /dev/null and b/apps/www/public/images/twitter-profiles/k0aPYRHF_400x400.jpg differ
diff --git a/apps/www/public/images/twitter-profiles/w8HLdlC7_400x400.jpg b/apps/www/public/images/twitter-profiles/w8HLdlC7_400x400.jpg
deleted file mode 100644
index be3de358bc69c..0000000000000
Binary files a/apps/www/public/images/twitter-profiles/w8HLdlC7_400x400.jpg and /dev/null differ
diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts
index 3d3762b524626..802ab87b7d34c 100644
--- a/packages/api-types/types/platform.d.ts
+++ b/packages/api-types/types/platform.d.ts
@@ -3764,7 +3764,11 @@ export interface paths {
}
get?: never
put?: never
- post?: never
+ /**
+ * Update publication for source
+ * @description Update a publication for a source. Requires bearer auth and an active, healthy project.
+ */
+ post: operations['ReplicationSourcesController_updatePublication']
/**
* Delete publication for source
* @description Delete a publication for a source. Requires bearer auth and an active, healthy project.
@@ -10062,6 +10066,21 @@ export interface components {
*/
version_id: number
}
+ UpdateReplicationPublicationBody: {
+ /** @description Publication tables */
+ tables: {
+ /**
+ * @description Table name
+ * @example orders
+ */
+ name: string
+ /**
+ * @description Table schema
+ * @example public
+ */
+ schema: string
+ }[]
+ }
UpdateSecretsConfigBody: {
change_tracking_id: string
jwt_secret: string
@@ -23587,6 +23606,63 @@ export interface operations {
}
}
}
+ ReplicationSourcesController_updatePublication: {
+ parameters: {
+ query?: never
+ header?: never
+ path: {
+ /** @description Publication name */
+ publication_name: string
+ /** @description Project ref */
+ ref: string
+ /** @description Source id */
+ source_id: number
+ }
+ cookie?: never
+ }
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['UpdateReplicationPublicationBody']
+ }
+ }
+ responses: {
+ /** @description Publication updated. */
+ 200: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ /** @description Unauthorized */
+ 401: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ /** @description Forbidden action */
+ 403: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ /** @description Rate limit exceeded */
+ 429: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ /** @description Unexpected error while updating publication. */
+ 500: {
+ headers: {
+ [name: string]: unknown
+ }
+ content?: never
+ }
+ }
+ }
ReplicationSourcesController_deletePublication: {
parameters: {
query?: never
diff --git a/packages/common/gotrue.ts b/packages/common/gotrue.ts
index 7a241cf4ce1ab..d2e4ef089419d 100644
--- a/packages/common/gotrue.ts
+++ b/packages/common/gotrue.ts
@@ -115,6 +115,9 @@ const logIndexedDB = (message: string, ...args: any[]) => {
})()
}
+/**
+ * Reference to a function that captures exceptions for debugging purposes to be sent to Sentry.
+ */
let captureException: ((e: any) => any) | null = null
export function setCaptureException(fn: typeof captureException) {
diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts
index 9c1e97d46a299..3afd9419f2e89 100644
--- a/packages/common/telemetry-constants.ts
+++ b/packages/common/telemetry-constants.ts
@@ -2010,6 +2010,24 @@ export interface CommandMenuCommandClickedEvent {
groups: Partial
}
+/**
+ * User opened a sidebar panel.
+ *
+ * @group Events
+ * @source studio
+ * @page Various pages with sidebar buttons
+ */
+export interface SidebarOpenedEvent {
+ action: 'sidebar_opened'
+ properties: {
+ /**
+ * The sidebar panel that was opened, e.g. ai-assistant, editor-panel, advisor-panel
+ */
+ sidebar: 'ai-assistant' | 'editor-panel' | 'advisor-panel'
+ }
+ groups: TelemetryGroups
+}
+
/**
* User was exposed to the table quickstart experiment.
*
@@ -2286,3 +2304,4 @@ export type TelemetryEvent =
| CommandMenuOpenedEvent
| CommandMenuSearchSubmittedEvent
| CommandMenuCommandClickedEvent
+ | SidebarOpenedEvent
diff --git a/packages/shared-data/index.ts b/packages/shared-data/index.ts
index 976896e2fe9cc..9f334c453e849 100644
--- a/packages/shared-data/index.ts
+++ b/packages/shared-data/index.ts
@@ -3,23 +3,24 @@ import extensions from './extensions.json'
import logConstants from './logConstants'
import { plans, PricingInformation } from './plans'
import { pricing } from './pricing'
-import { products, PRODUCT_MODULES } from './products'
+import { PRODUCT_MODULES, products } from './products'
import questions from './questions'
import type { AWS_REGIONS_KEYS, CloudProvider, Region } from './regions'
import { AWS_REGIONS, FLY_REGIONS } from './regions'
-import tweets from './tweets'
+import tweets, { topTweets } from './tweets'
-export type { AWS_REGIONS_KEYS, CloudProvider, PricingInformation, Region }
export {
AWS_REGIONS,
- FLY_REGIONS,
config,
extensions,
+ FLY_REGIONS,
logConstants,
plans,
pricing,
- products,
PRODUCT_MODULES,
+ products,
questions,
+ topTweets,
tweets,
}
+export type { AWS_REGIONS_KEYS, CloudProvider, PricingInformation, Region }
diff --git a/packages/shared-data/tweets.ts b/packages/shared-data/tweets.ts
index cba89c1a39bf0..92120a4123b09 100644
--- a/packages/shared-data/tweets.ts
+++ b/packages/shared-data/tweets.ts
@@ -60,7 +60,7 @@ const tweets = [
img_url: '/images/twitter-profiles/ukFtCkww_400x400.jpg',
},
{
- text: 'Lately been using Supabase over AWS/ GCP for products to save on costs and rapid builds(Vibe Code) that do not need all the Infra and the hefty costs that come with AWS/ GCP out the door. Great solution overall. Love the new Feature stack thats implemented',
+ text: 'Lately been using Supabase over AWS/ GCP for products to save on costs and rapid builds(Vibe Code) that do not need all the Infra and the hefty costs that come with AWS/ GCP out the door. Great solution overall.',
url: 'https://x.com/xthemadgeniusx/status/1960049950110384250',
handle: 'xthemadgeniusx',
img_url: '/images/twitter-profiles/XE8Oyngj_400x400.jpg',
@@ -78,7 +78,7 @@ const tweets = [
img_url: '/images/twitter-profiles/GtrVV2dD_400x400.jpg',
},
{
- text: '@supabase is just 🤯 Now I see why a lot of people love using it as a backend for their applications. I am really impressed with how easy it is to set up an Auth and then just code it together for the frontend. @IngoKpp now I see your joy with Supabase #coding #fullstackwebdev',
+ text: '@supabase is just 🤯 Now I see why a lot of people love using it as a backend for their applications. I am really impressed with how easy it is to set up an Auth and then just code it together for the frontend.',
url: 'https://twitter.com/IxoyeDesign/status/1497473731777728512',
handle: 'IxoyeDesign',
img_url: '/images/twitter-profiles/C8opIL-g_400x400.jpg',
@@ -138,7 +138,7 @@ const tweets = [
img_url: '/images/twitter-profiles/rWX8Jzp5_400x400.jpg',
},
{
- text: 'There are a lot of indie hackers building in public, but it’s rare to see a startup shipping as consistently and transparently as Supabase. Their upcoming March releases look to be 🔥 Def worth a follow! also opened my eyes as to how to value add in open source.',
+ text: 'There are a lot of indie hackers building in public, but it’s rare to see a startup shipping as consistently and transparently as Supabase. Their upcoming March releases look to be 🔥 Def worth a follow!',
url: 'https://twitter.com/swyx/status/1366685025047994373',
handle: 'swyx',
img_url: '/images/twitter-profiles/qhvO9V6x_400x400.jpg',
@@ -162,7 +162,7 @@ const tweets = [
img_url: '/images/twitter-profiles/7NITI8Z3_400x400.jpg',
},
{
- text: 'This community is STRONG and will continue to be the reason why developers flock to @supabase over an alternative. Keep up the good work! ⚡️',
+ text: 'This community is STRONG and will continue to be the reason why developers flock to @supabase over an alternative.',
url: 'https://twitter.com/_wilhelm__/status/1524074865107488769',
handle: '_wilhelm__',
img_url: '/images/twitter-profiles/CvqDy6YF_400x400.jpg',
@@ -174,7 +174,7 @@ const tweets = [
img_url: '/images/twitter-profiles/bJlKtSxz_400x400.jpg',
},
{
- text: '@supabase Putting a ton of well-explained example API queries in a self-building documentation is just a classy move all around. I also love having GraphQL-style nested queries with traditional SQL filtering. This is pure DX delight. A+++. #backend',
+ text: '@supabase Putting a ton of well-explained example API queries in a self-building documentation is just a classy move all around. I also love having GraphQL-style nested queries with traditional SQL filtering. This is pure DX delight. A+++.',
url: 'https://twitter.com/CodiferousCoder/status/1522233113207836675',
handle: 'CodiferousCoder',
img_url: '/images/twitter-profiles/t37cVLwy_400x400.jpg',
@@ -191,12 +191,6 @@ const tweets = [
handle: 'JP__Gallegos',
img_url: '/images/twitter-profiles/1PH2mt6v_400x400.jpg',
},
- {
- text: 'Check out this amazing product @supabase. A must give try #newidea #opportunity',
- url: 'https://twitter.com/digitaldaswani/status/1364447219642814464',
- handle: 'digitaldaswani',
- img_url: '/images/twitter-profiles/w8HLdlC7_400x400.jpg',
- },
{
text: "I gave @supabase a try this weekend and I was able to create a quick dashboard to visualize the data from the PostgreSQL instance. It's super easy to use Supabase's API or the direct DB connection. Check out the tutorial 📖",
url: 'https://twitter.com/razvanilin/status/1363770020581412867',
@@ -215,12 +209,6 @@ const tweets = [
handle: 'razvanilin',
img_url: '/images/twitter-profiles/AiaH9vJ2_400x400.jpg',
},
- {
- text: "Wait. Is it so easy to write queries for @supabase ? It's like simple SQL stuff!",
- url: 'https://twitter.com/T0ny_Boy/status/1362911838908911617',
- handle: 'T0ny_Boy',
- img_url: '/images/twitter-profiles/UCBhUBZl_400x400.jpg',
- },
{
text: 'Jeez, and @supabase have native support for magic link login?! I was going to use http://magic.link for this But if I can get my whole DB + auth + magic link support in one... Awesome',
url: 'https://twitter.com/louisbarclay/status/1362016666868154371',
@@ -256,6 +244,7 @@ const tweets = [
url: 'https://twitter.com/nerdburn/status/1356857261495214085',
handle: 'nerdburn',
img_url: '/images/twitter-profiles/66VSV9Mm_400x400.png',
+ weight: 10,
},
{
text: 'Now things are starting to get interesting! Firebase has long been the obvious choice for many #flutter devs for the ease of use. But their databases are NoSQL, which has its downsides... Seems like @supabase is working on something interesting here!',
@@ -287,6 +276,76 @@ const tweets = [
handle: '0xBanana',
img_url: '/images/twitter-profiles/pgHIGqZ0_400x400.jpg',
},
+ {
+ text: `Very impressed by @supabase's growth. For new startups, they seem to have gone from "promising" to "standard" in remarkably short order.`,
+ url: 'https://x.com/patrickc/status/1979157875600617913',
+ handle: 'patrickc',
+ img_url: '/images/twitter-profiles/_iAaSUQf_400x400.jpg',
+ weight: 10,
+ },
+ {
+ text: `Okay, I finally tried Supabase today and wow... why did I wait so long? 😅 Went from 'how do I even start' to having auth + database + real-time updates working in like 20 minutes. Sometimes the hype is actually justified! #Supabase`,
+ url: 'https://x.com/Aliahsan_sfv/status/1967167095894098210',
+ handle: 'Aliahsan_sfv',
+ img_url: '/images/twitter-profiles/2SQwtv8c_400x400.jpg',
+ weight: 9,
+ },
+ {
+ text: `Supabase is the best product experience I've had in years.\nNot just tech - taste.\nFrom docs to latency to the URL structure that makes you think "oh, that's obvious"\nFeels like every other platform should study how they built it\n@supabase I love you`,
+ url: 'https://x.com/yatsiv_yuriy/status/1979182362480071162',
+ handle: 'yatsiv_yuriy',
+ img_url: '/images/twitter-profiles/Y1swF6ef_400x400.jpg',
+ weight: 9,
+ },
+ {
+ text: "@supabase shout out, their MCP is awesome. It's helping me create better row securities and telling me best practises for setting up a supabase app",
+ url: 'https://x.com/adeelibr/status/1981356783818985774',
+ handle: 'adeelibr',
+ img_url: '/images/twitter-profiles/k0aPYRHF_400x400.jpg',
+ weight: 6,
+ },
]
+export const getWeightedTweets = (count: number): typeof tweets => {
+ const fallbackWeight = 1
+ const availableTweets = [...tweets]
+ const selectedTweets: typeof tweets = []
+ let remainingWeight = availableTweets.reduce(
+ (sum, tweet) => sum + (tweet.weight ?? fallbackWeight),
+ 0
+ )
+
+ for (let i = 0; i < count && availableTweets.length > 0; i++) {
+ // Generate random number between 0 and remainingWeight
+ const random = Math.random() * remainingWeight
+
+ // Find the selected tweet based on cumulative weights
+ let accumulatedWeight = 0
+ let selectedIndex = -1
+
+ for (let j = 0; j < availableTweets.length; j++) {
+ const tweet = availableTweets[j]
+ const weight = tweet.weight ?? fallbackWeight
+ accumulatedWeight += weight
+ if (random <= accumulatedWeight) {
+ selectedTweets.push(tweet)
+ selectedIndex = j
+ break
+ }
+ }
+
+ // Remove the selected tweet and update remaining weight
+ if (selectedIndex !== -1) {
+ const removedWeight = availableTweets[selectedIndex].weight ?? fallbackWeight
+ remainingWeight -= removedWeight
+ availableTweets.splice(selectedIndex, 1)
+ }
+ }
+
+ return selectedTweets
+}
+
+// Sort by weight (highest first), then take first 18 for static pages
+export const topTweets = [...tweets].sort((a, b) => (b.weight ?? 1) - (a.weight ?? 1)).slice(0, 18)
+
export default tweets
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e90cc80e0b287..49e35824adb69 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -397,7 +397,7 @@ importers:
version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@sentry/nextjs':
specifier: ^10.3.0
- version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)
+ version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)
'@supabase/supabase-js':
specifier: 'catalog:'
version: 2.75.1
@@ -750,9 +750,6 @@ importers:
'@dagrejs/dagre':
specifier: ^1.0.4
version: 1.0.4
- '@deno/eszip':
- specifier: 0.83.0
- version: 0.83.0
'@dnd-kit/core':
specifier: ^6.1.0
version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -789,6 +786,9 @@ importers:
'@hookform/resolvers':
specifier: ^3.1.1
version: 3.3.1(react-hook-form@7.47.0(react@18.3.1))
+ '@mjackson/multipart-parser':
+ specifier: ^0.10.1
+ version: 0.10.1
'@modelcontextprotocol/sdk':
specifier: ^1.18.0
version: 1.18.0(supports-color@8.1.1)
@@ -812,7 +812,7 @@ importers:
version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@sentry/nextjs':
specifier: ^10.3.0
- version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)
+ version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)
'@std/path':
specifier: npm:@jsr/std__path@^1.0.8
version: '@jsr/std__path@1.0.8'
@@ -1227,7 +1227,7 @@ importers:
version: 2.11.3(@types/node@22.13.14)(typescript@5.9.2)
next-router-mock:
specifier: ^0.9.13
- version: 0.9.13(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)
+ version: 0.9.13(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)
node-mocks-http:
specifier: ^1.17.2
version: 1.17.2(@types/node@22.13.14)
@@ -1591,7 +1591,7 @@ importers:
version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@sentry/nextjs':
specifier: ^10
- version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)
+ version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)
'@supabase/supabase-js':
specifier: 'catalog:'
version: 2.75.1
@@ -2576,7 +2576,7 @@ importers:
version: link:../api-types
next-router-mock:
specifier: ^0.9.13
- version: 0.9.13(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)
+ version: 0.9.13(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)
tsx:
specifier: 'catalog:'
version: 4.20.3
@@ -3429,15 +3429,9 @@ packages:
'@date-fns/tz@1.2.0':
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
- '@deno/eszip@0.83.0':
- resolution: {integrity: sha512-gTKYMQ+uv20IUJuEBYkjovMPflFjX7caJ8cwA/sZVqic0L/PFP2gZMFt/GiCHc8eVejhlJLGxg0J4qehDq/f2A==}
-
'@deno/shim-deno-test@0.5.0':
resolution: {integrity: sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==}
- '@deno/shim-deno@0.18.2':
- resolution: {integrity: sha512-oQ0CVmOio63wlhwQF75zA4ioolPvOwAoK0yuzcS5bDC1JUvH3y1GS8xPh8EOpcoDQRU4FTG8OQfxhpR+c6DrzA==}
-
'@deno/shim-deno@0.19.2':
resolution: {integrity: sha512-q3VTHl44ad8T2Tw2SpeAvghdGOjlnLPDNO2cpOxwMrBE/PVas6geWpbpIgrM+czOCH0yejp0yi8OaTuB+NU40Q==}
@@ -21852,18 +21846,8 @@ snapshots:
'@date-fns/tz@1.2.0': {}
- '@deno/eszip@0.83.0':
- dependencies:
- '@deno/shim-deno': 0.18.2
- undici: 6.21.2
-
'@deno/shim-deno-test@0.5.0': {}
- '@deno/shim-deno@0.18.2':
- dependencies:
- '@deno/shim-deno-test': 0.5.0
- which: 4.0.0
-
'@deno/shim-deno@0.19.2':
dependencies:
'@deno/shim-deno-test': 0.5.0
@@ -27630,7 +27614,7 @@ snapshots:
'@sentry/core@10.3.0': {}
- '@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)':
+ '@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.36.0
@@ -36735,7 +36719,7 @@ snapshots:
dependencies:
js-yaml-loader: 1.2.2
- next-router-mock@0.9.13(next@15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1):
+ next-router-mock@0.9.13(next@15.5.2(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1):
dependencies:
next: 15.5.2(@babel/core@7.28.4(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
react: 18.3.1