diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index fc94395ea85..0a14ada3678 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -93,6 +93,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "admonition-responsive": { + name: "admonition-responsive", + type: "components:example", + registryDependencies: ["admonition"], + component: React.lazy(() => import("@/registry/default/example/admonition-responsive")), + source: "", + files: ["registry/default/example/admonition-responsive.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "admonition-button": { name: "admonition-button", type: "components:example", diff --git a/apps/design-system/content/docs/fragments/admonition.mdx b/apps/design-system/content/docs/fragments/admonition.mdx index 0143008fcd9..53337a3efb6 100644 --- a/apps/design-system/content/docs/fragments/admonition.mdx +++ b/apps/design-system/content/docs/fragments/admonition.mdx @@ -49,6 +49,18 @@ Only ever use the `primary` (green) button `type` on a `default` Admonition. Eve className="[&_.preview>[data-orientation=vertical]]:sm:max-w-[70%]" /> +## Responsive + +{' '} + + +Reize your browser to see the button(s) change `layout` based on the Admonition’s width. + +When `layout="responsive"`, the Alert root gets `@container` so the Admonition is the container-query context. The Admonition stays `vertical` when it’s narrow and switches to `horizontal` when its own width reaches the `@md` container breakpoint, independent of page width. + ### Warning Go to Compute and Disk} + /> + ) +} diff --git a/apps/design-system/registry/default/example/resizable-demo-with-handle.tsx b/apps/design-system/registry/default/example/resizable-demo-with-handle.tsx index fe58cf9aa4b..e68f6869109 100644 --- a/apps/design-system/registry/default/example/resizable-demo-with-handle.tsx +++ b/apps/design-system/registry/default/example/resizable-demo-with-handle.tsx @@ -2,22 +2,22 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' export default function ResizableDemo() { return ( - - + +
One
- - - + + +
Two
- +
Three
diff --git a/apps/design-system/registry/default/example/resizable-demo.tsx b/apps/design-system/registry/default/example/resizable-demo.tsx index 521427569b1..9e0ea6354fa 100644 --- a/apps/design-system/registry/default/example/resizable-demo.tsx +++ b/apps/design-system/registry/default/example/resizable-demo.tsx @@ -2,22 +2,22 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' export default function ResizableDemo() { return ( - - + +
One
- - - + + +
Two
- +
Three
diff --git a/apps/design-system/registry/default/example/resizable-handle.tsx b/apps/design-system/registry/default/example/resizable-handle.tsx index 29a14b6ca5d..517e5104ddf 100644 --- a/apps/design-system/registry/default/example/resizable-handle.tsx +++ b/apps/design-system/registry/default/example/resizable-handle.tsx @@ -3,16 +3,16 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' export default function ResizableDemo() { return ( - +
Sidebar
- +
Content
diff --git a/apps/design-system/registry/default/example/resizable-vertical.tsx b/apps/design-system/registry/default/example/resizable-vertical.tsx index 6b00df0180e..367fef3bb89 100644 --- a/apps/design-system/registry/default/example/resizable-vertical.tsx +++ b/apps/design-system/registry/default/example/resizable-vertical.tsx @@ -2,14 +2,17 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' export default function ResizableDemo() { return ( - - + +
Header
- +
Content
diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index ad110f54fd0..6085f8a9fa0 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -13,6 +13,12 @@ export const examples: Registry = [ registryDependencies: ['admonition'], files: ['example/admonition-demo.tsx'], }, + { + name: 'admonition-responsive', + type: 'components:example', + registryDependencies: ['admonition'], + files: ['example/admonition-responsive.tsx'], + }, { name: 'admonition-button', type: 'components:example', diff --git a/apps/docs/app/contributing/content.mdx b/apps/docs/app/contributing/content.mdx index 1bff38834c0..0181d07022f 100644 --- a/apps/docs/app/contributing/content.mdx +++ b/apps/docs/app/contributing/content.mdx @@ -335,7 +335,9 @@ If your image has alternate light and dark versions, or you want to make it zoom dark: '/docs/img/supabase-architecture.svg', light: '/docs/img/supabase-architecture--light.svg', }} - zoomable + width={1600} + height={767} + /> ``` @@ -345,7 +347,9 @@ If your image has alternate light and dark versions, or you want to make it zoom dark: '/docs/img/supabase-architecture.svg', light: '/docs/img/supabase-architecture--light.svg', }} - zoomable + width={1600} + height={767} + /> ### Project Variables diff --git a/apps/docs/components/Image.tsx b/apps/docs/components/Image.tsx new file mode 100644 index 00000000000..bd89a77272b --- /dev/null +++ b/apps/docs/components/Image.tsx @@ -0,0 +1,64 @@ +'use client' + +import { useTheme } from 'next-themes' +import NextImage, { ImageProps as NextImageProps } from 'next/image' + +interface StaticImageData { + src: string + height: number + width: number + blurDataURL?: string + blurWidth?: number + blurHeight?: number +} + +interface StaticRequire { + default: StaticImageData +} +type StaticImport = StaticRequire | StaticImageData + +type SourceType = + | string + | { + dark: string | StaticImport + light: string | StaticImport + } + +export interface ImageProps extends Omit { + src: SourceType + caption?: string + containerClassName?: string +} + +/** + * + * This is a shrunk version of the `ui` package Image component. Because of + * Cumulative Layout Shift caused by problems stated in this PR + * https://github.com/supabase/supabase/pull/43026/ that's affecting hash + * navigation, and the need to support captions and light/dark image versions. + * + * Ideally we should solve these issues in that component and re-use it again, + * making sure it doesn't affect other projects consuming the component. + * + */ +const Image = ({ src, alt = '', ...props }: ImageProps) => { + const { resolvedTheme } = useTheme() + const source = + typeof src === 'string' ? src : resolvedTheme?.includes('dark') ? src.dark : src.light + + return ( +
+ + {props.caption &&
{props.caption}
} +
+ ) +} + +export default Image diff --git a/apps/docs/content/guides/ai/choosing-compute-addon.mdx b/apps/docs/content/guides/ai/choosing-compute-addon.mdx index 7c36ccfdd72..e4f8c1ae905 100644 --- a/apps/docs/content/guides/ai/choosing-compute-addon.mdx +++ b/apps/docs/content/guides/ai/choosing-compute-addon.mdx @@ -126,7 +126,9 @@ It is possible to upload more vectors to a single table if Memory allows it (for light: '/docs/img/ai/instance-type/hnsw-dims--light.png', dark: '/docs/img/ai/instance-type/hnsw-dims--dark.png', }} - zoomable + width={1427} + height={862} + /> ## IVFFlat @@ -260,7 +262,9 @@ For 1,000,000 vectors 40 probes results to accuracy of 0.98. Note that exact val light: '/docs/img/ai/going-prod/size-to-rps--light.png', dark: '/docs/img/ai/going-prod/size-to-rps--dark.png', }} - zoomable + width={1427} + height={862} + /> @@ -296,7 +300,9 @@ You can increase the Requests per Second by increasing `m` and `ef_construction` light: '/docs/img/ai/going-prod/dbpedia-hnsw-build-parameters--light.png', dark: '/docs/img/ai/going-prod/dbpedia-hnsw-build-parameters--dark.png', }} - zoomable + width={1052} + height={796} + /> @@ -308,7 +314,9 @@ You can increase the Requests per Second by increasing `m` and `ef_construction` light: '/docs/img/ai/instance-type/lists-for-1m--light.png', dark: '/docs/img/ai/instance-type/lists-for-1m--dark.png', }} - zoomable + width={1032} + height={796} + /> @@ -327,7 +335,9 @@ We follow techniques outlined in the [ANN Benchmarks](https://github.com/erikber dark: '/docs/img/ai/instance-type/vecs-benchmark--dark.png', }} className="max-h-[650px]" - zoomable + width={1196} + height={1194} + /> Each test is run for a minimum of 30-40 minutes. They include a series of experiments executed at different concurrency levels to measure the engine's performance under different load types. The results are then averaged. diff --git a/apps/docs/content/guides/ai/engineering-for-scale.mdx b/apps/docs/content/guides/ai/engineering-for-scale.mdx index 8159a4b1465..55e8990507d 100644 --- a/apps/docs/content/guides/ai/engineering-for-scale.mdx +++ b/apps/docs/content/guides/ai/engineering-for-scale.mdx @@ -20,7 +20,9 @@ If you've used [Vecs](/docs/guides/ai/vecs-python-client) to create 3 different light: '/docs/img/ai/scaling/engineering-for-scale--single-database--light.png', dark: '/docs/img/ai/scaling/engineering-for-scale--single-database--dark.png', }} - zoomable + width={1600} + height={1145} + /> For example, with 3 collections, called `docs`, `posts`, and `images`, we could expose the "docs" inside the public schema like this: @@ -55,7 +57,9 @@ As you move into production, we recommend splitting your collections into separa light: '/docs/img/ai/scaling/engineering-for-scale--with-secondaries--light.png', dark: '/docs/img/ai/scaling/engineering-for-scale--with-secondaries--dark.png', }} - zoomable + width={1600} + height={1641} + /> You can use as many secondary databases as you need to manage your collections. With this architecture, you have 2 options for accessing collections within your application: @@ -138,5 +142,7 @@ This diagram provides an example architecture that allows you to access the coll light: '/docs/img/ai/scaling/engineering-for-scale--multi-database--light.png', dark: '/docs/img/ai/scaling/engineering-for-scale--multi-database--dark.png', }} - zoomable + width={1600} + height={1754} + /> diff --git a/apps/docs/content/guides/ai/examples/building-chatgpt-plugins.mdx b/apps/docs/content/guides/ai/examples/building-chatgpt-plugins.mdx index 468df1a8d55..f0c72e677a7 100644 --- a/apps/docs/content/guides/ai/examples/building-chatgpt-plugins.mdx +++ b/apps/docs/content/guides/ai/examples/building-chatgpt-plugins.mdx @@ -37,7 +37,9 @@ We'll be saving the Postgres documentation in Postgres, and ChatGPT will be retr light: '/docs/img/ai/chatgpt-plugins/chatgpt-plugin-scheme--light.png', dark: '/docs/img/ai/chatgpt-plugins/chatgpt-plugin-scheme--dark.png', }} - zoomable + + width={1196} + height={1194} /> ### Step 1: Fork the ChatGPT Retrieval Plugin repository diff --git a/apps/docs/content/guides/ai/going-to-prod.mdx b/apps/docs/content/guides/ai/going-to-prod.mdx index 388f714dd0d..ff7ceb25647 100644 --- a/apps/docs/content/guides/ai/going-to-prod.mdx +++ b/apps/docs/content/guides/ai/going-to-prod.mdx @@ -34,7 +34,9 @@ On the other hand, if you need to scale your application, you will need to [crea light: '/docs/img/ai/going-prod/dbpedia-ivfflat-vs-hnsw-4xl--light.png', dark: '/docs/img/ai/going-prod/dbpedia-ivfflat-vs-hnsw-4xl--dark.png', }} - zoomable + width={1052} + height={796} + /> ## HNSW, understanding `ef_construction`, `ef_search`, and `m` @@ -55,7 +57,9 @@ Search parameters: light: '/docs/img/ai/going-prod/dbpedia-hnsw-build-parameters--light.png', dark: '/docs/img/ai/going-prod/dbpedia-hnsw-build-parameters--dark.png', }} - zoomable + width={1052} + height={796} + /> ## IVFFlat, understanding `probes` and `lists` @@ -76,7 +80,9 @@ You can find more examples of how `lists` and `probes` constants affect accuracy light: '/docs/img/ai/going-prod/lists-count--light.png', dark: '/docs/img/ai/going-prod/lists-count--dark.png', }} - zoomable + width={1467} + height={808} + /> ## Performance tips when using indexes @@ -117,5 +123,7 @@ Or take a look at our [pgvector 0.5.0 performance](/blog/increase-performance-pg light: '/docs/img/ai/going-prod/size-to-rps--light.png', dark: '/docs/img/ai/going-prod/size-to-rps--dark.png', }} - zoomable + width={1427} + height={862} + /> diff --git a/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx b/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx index 0fba3bbb296..906e2b9c9a2 100644 --- a/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx +++ b/apps/docs/content/guides/ai/vector-indexes/hnsw-indexes.mdx @@ -81,6 +81,8 @@ Skip lists are multi-layer linked lists. The bottom layer is a regular linked li light: '/docs/img/ai/vector-indexes/hnsw-indexes/skip-list--light.png', dark: '/docs/img/ai/vector-indexes/hnsw-indexes/skip-list--dark.png', }} + width={1400} + height={700} /> When searching for an element, the algorithm begins at the top layer and traverses its linked list horizontally. If the target element is found, the algorithm stops and returns it. Otherwise if the next element in the list is greater than the target (or `NULL`), the algorithm drops down to the next layer below. Since each layer below is less sparse than the layer above (with the bottom layer connecting all elements), the target will eventually be found. Skip lists offer O(log n) average complexity for both search and insertion/deletion. @@ -93,7 +95,9 @@ A navigable small world (NSW) is a special type of proximity graph that also inc alt="visual of an example navigable small world graph" src="/docs/img/ai/vector-indexes/hnsw-indexes/nsw.png" className="max-h-[600px] mx-auto" - zoomable + width={1016} + height={759} + /> The “navigable” part of NSW specifically refers to the ability to logarithmically scale the greedy search algorithm on the graph, an algorithm that attempts to make only the locally optimal choice at each hop. Without this property, the graph may still be considered a small world with short paths between far-away nodes, but the greedy algorithm tends to miss them. Greedy search is ideal for NSW because it is quick to navigate and has low computational costs. diff --git a/apps/docs/content/guides/auth/architecture.mdx b/apps/docs/content/guides/auth/architecture.mdx index ca2c134da55..b4c0a53092b 100644 --- a/apps/docs/content/guides/auth/architecture.mdx +++ b/apps/docs/content/guides/auth/architecture.mdx @@ -16,6 +16,8 @@ There are four major layers to Supabase Auth: dark: '/docs/img/supabase-architecture.svg', light: '/docs/img/supabase-architecture--light.svg', }} + width={1600} + height={767} /> ## Client layer diff --git a/apps/docs/content/guides/auth/auth-mfa/phone.mdx b/apps/docs/content/guides/auth/auth-mfa/phone.mdx index d1fcc3e86fc..c903dd95165 100644 --- a/apps/docs/content/guides/auth/auth-mfa/phone.mdx +++ b/apps/docs/content/guides/auth/auth-mfa/phone.mdx @@ -19,6 +19,8 @@ Below is a flow chart illustrating how the Enrollment and Verify APIs work in th dark: '/docs/img/guides/auth-mfa/auth-mfa-phone-flow.svg', }} containerClassName="max-w-[700px]" + width={93} + height={150} /> ### Add enrollment flow diff --git a/apps/docs/content/guides/auth/auth-mfa/totp.mdx b/apps/docs/content/guides/auth/auth-mfa/totp.mdx index cc6eed42b88..c645e15f681 100644 --- a/apps/docs/content/guides/auth/auth-mfa/totp.mdx +++ b/apps/docs/content/guides/auth/auth-mfa/totp.mdx @@ -19,6 +19,8 @@ Below is a flow chart illustrating how the Enrollment, Challenge, and Verify API dark: '/docs/img/guides/auth-mfa/auth-mfa-flow.svg', }} containerClassName="max-w-[700px]" + width={111} + height={150} /> [TOTP MFA API](/docs/reference/javascript/auth-mfa-api) is free to use and is enabled on all Supabase projects by default. diff --git a/apps/docs/content/guides/auth/quickstarts/with-expo-react-native-social-auth.mdx b/apps/docs/content/guides/auth/quickstarts/with-expo-react-native-social-auth.mdx index 2f6e0a9206f..4b82f772731 100644 --- a/apps/docs/content/guides/auth/quickstarts/with-expo-react-native-social-auth.mdx +++ b/apps/docs/content/guides/auth/quickstarts/with-expo-react-native-social-auth.mdx @@ -53,42 +53,12 @@ Now, create a helper file to initialize the Supabase client for both web and Rea queryGroup="auth-store" > -{/* TODO: Future task to extract to repo and transclude */} - <$CodeTabs> - ```ts name=lib/supabase.web.ts - import AsyncStorage from '@react-native-async-storage/async-storage'; - import { createClient } from '@supabase/supabase-js'; - import 'react-native-url-polyfill/auto'; - - const ExpoWebSecureStoreAdapter = { - getItem: (key: string) => { - console.debug("getItem", { key }) - return AsyncStorage.getItem(key) - }, - setItem: (key: string, value: string) => { - return AsyncStorage.setItem(key, value) - }, - removeItem: (key: string) => { - return AsyncStorage.removeItem(key) - }, - }; - - export const supabase = createClient( - process.env.EXPO_PUBLIC_SUPABASE_URL ?? '', - process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? '', - { - auth: { - storage: ExpoWebSecureStoreAdapter, - autoRefreshToken: true, - persistSession: true, - detectSessionInUrl: false, - }, - }, - ); - ``` - - + <$CodeSample + path="/auth/expo-social-auth/lib/supabase.web.ts" + lines={[[1, -1]]} + meta="name=lib/supabase.web.ts" + /> @@ -96,49 +66,18 @@ Now, create a helper file to initialize the Supabase client for both web and Rea If you want to encrypt the user's session information, use `aes-js` and store the encryption key in [Expo SecureStore](https://docs.expo.dev/versions/latest/sdk/securestore). The [`aes-js` library](https://github.com/ricmoo/aes-js) is a reputable JavaScript-only implementation of the AES encryption algorithm in CTR mode. A new 256-bit encryption key is generated using the `react-native-get-random-values` library. This key is stored inside Expo's SecureStore, while the value is encrypted and placed inside AsyncStorage. Make sure that: + - You keep the `expo-secure-storage`, `aes-js` and `react-native-get-random-values` libraries up-to-date. - Choose the correct [`SecureStoreOptions`](https://docs.expo.dev/versions/latest/sdk/securestore/#securestoreoptions) for your app's needs. E.g. [`SecureStore.WHEN_UNLOCKED`](https://docs.expo.dev/versions/latest/sdk/securestore/#securestorewhen_unlocked) regulates when the data can be accessed. - Carefully consider optimizations or other modifications to the above example, as those can lead to introducing subtle security vulnerabilities. Implement a `ExpoSecureStoreAdapter` to pass in as Auth storage adapter for the `supabase-js` client: - <$CodeTabs> - - ```ts name=lib/supabase.ts - import { createClient } from '@supabase/supabase-js'; - import { deleteItemAsync, getItemAsync, setItemAsync } from 'expo-secure-store'; - - const ExpoSecureStoreAdapter = { - getItem: (key: string) => { - console.debug("getItem", { key, getItemAsync }) - return getItemAsync(key) - }, - setItem: (key: string, value: string) => { - if (value.length > 2048) { - console.warn('Value being stored in SecureStore is larger than 2048 bytes and it may not be stored successfully. In a future SDK version, this call may throw an error.') - } - return setItemAsync(key, value) - }, - removeItem: (key: string) => { - return deleteItemAsync(key) - }, - }; - - export const supabase = createClient( - process.env.EXPO_PUBLIC_SUPABASE_URL ?? '', - process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? '', - { - auth: { - storage: ExpoSecureStoreAdapter as any, - autoRefreshToken: true, - persistSession: true, - detectSessionInUrl: false, - }, - }, - ); - ``` - - + <$CodeSample + path="/auth/expo-social-auth/lib/supabase.ts" + lines={[[1, -1]]} + meta="name=lib/supabase.ts" + /> @@ -150,14 +89,11 @@ These variables are safe to expose in your Expo app since Supabase has [Row Leve Create a `.env` file containing these variables: -<$CodeTabs> - -```bash name=.env -EXPO_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL -EXPO_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY -``` - - +<$CodeSample +path="/auth/expo-social-auth/.env.template" +lines={[[1, -1]]} +meta="name=.env" +/> ### Set up protected navigation @@ -167,279 +103,59 @@ Next, you need to protect app navigation to prevent unauthenticated users from a Create [a React context](https://react.dev/learn/passing-data-deeply-with-context) to manage the authentication session, making it accessible from any component: -<$CodeTabs> - -```tsx name=hooks/use-auth-context.tsx -import { Session } from '@supabase/supabase-js' -import { createContext, useContext } from 'react' - -export type AuthData = { - session?: Session | null - profile?: any | null - isLoading: boolean - isLoggedIn: boolean -} - -export const AuthContext = createContext({ - session: undefined, - profile: undefined, - isLoading: true, - isLoggedIn: false, -}) - -export const useAuthContext = () => useContext(AuthContext) -``` - - +<$CodeSample +path="/auth/expo-social-auth/hooks/use-auth-context.tsx" +lines={[[1, -1]]} +meta="name=hooks/use-auth-context.tsx" +/> #### Create the `AuthProvider` Next, create a provider component to manage the authentication session throughout the app: -<$CodeTabs> - -```tsx name=providers/auth-provider.tsx -import { AuthContext } from '@/hooks/use-auth-context' -import { supabase } from '@/lib/supabase' -import type { Session } from '@supabase/supabase-js' -import { PropsWithChildren, useEffect, useState } from 'react' - -export default function AuthProvider({ children }: PropsWithChildren) { - const [session, setSession] = useState() - const [profile, setProfile] = useState() - const [isLoading, setIsLoading] = useState(true) - - // Fetch the session once, and subscribe to auth state changes - useEffect(() => { - const fetchSession = async () => { - setIsLoading(true) - - const { - data: { session }, - error, - } = await supabase.auth.getSession() - - if (error) { - console.error('Error fetching session:', error) - } - - setSession(session) - setIsLoading(false) - } - - fetchSession() - - const { - data: { subscription }, - } = supabase.auth.onAuthStateChange((_event, session) => { - console.log('Auth state changed:', { event: _event, session }) - setSession(session) - }) - - // Cleanup subscription on unmount - return () => { - subscription.unsubscribe() - } - }, []) - - // Fetch the profile when the session changes - useEffect(() => { - const fetchProfile = async () => { - setIsLoading(true) - - if (session) { - const { data } = await supabase - .from('profiles') - .select('*') - .eq('id', session.user.id) - .single() - - setProfile(data) - } else { - setProfile(null) - } - - setIsLoading(false) - } - - fetchProfile() - }, [session]) - - return ( - - {children} - - ) -} -``` - - +<$CodeSample +path="/auth/expo-social-auth/providers/auth-provider.tsx" +lines={[[1, -1]]} +meta="name=providers/auth-provider.tsx" +/> #### Create the `SplashScreenController` Create a `SplashScreenController` component to display the [Expo `SplashScreen`](https://docs.expo.dev/versions/latest/sdk/splash-screen/) while the authentication session is loading: -<$CodeTabs> - -```tsx name=components/splash-screen-controller.tsx -import { useAuthContext } from '@/hooks/use-auth-context' -import { SplashScreen } from 'expo-router' - -SplashScreen.preventAutoHideAsync() - -export function SplashScreenController() { - const { isLoading } = useAuthContext() - - if (!isLoading) { - SplashScreen.hideAsync() - } - - return null -} -``` - - +<$CodeSample +path="/auth/expo-social-auth/components/splash-screen-controller.tsx" +lines={[[1, -1]]} +meta="name=components/splash-screen-controller.tsx" +/> ### Create a logout component Create a logout button component to handle user sign-out: -<$CodeTabs> - -```tsx name=components/social-auth-buttons/sign-out-button.tsx -import { supabase } from '@/lib/supabase' -import React from 'react' -import { Button } from 'react-native' - -async function onSignOutButtonPress() { - const { error } = await supabase.auth.signOut() - - if (error) { - console.error('Error signing out:', error) - } -} - -export default function SignOutButton() { - return + ) : null } diff --git a/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx b/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx index 07817830d7e..a69ca95d9aa 100644 --- a/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx +++ b/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx @@ -1,23 +1,23 @@ -import { X } from 'lucide-react' -import { useRef } from 'react' -import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid' -import ReactMarkdown from 'react-markdown' - import { useParams } from 'common' import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants' import { LintCategoryBadge, LintEntity, - NoIssuesFound, lintInfoMap, + NoIssuesFound, } from 'components/interfaces/Linter/Linter.utils' import { Lint } from 'data/lint/lint-query' -import { useRouter } from 'next/router' import { useTrack } from 'lib/telemetry/track' -import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui' +import { X } from 'lucide-react' +import { useRouter } from 'next/router' +import { useRef } from 'react' +import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid' +import ReactMarkdown from 'react-markdown' +import { Button, cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { EntityTypeIcon } from './Linter.utils' + import LintDetail from './LintDetail' +import { EntityTypeIcon } from './Linter.utils' interface LinterDataGridProps { isLoading: boolean @@ -117,11 +117,11 @@ const LinterDataGrid = ({ return ( - +
diff --git a/apps/studio/components/interfaces/Reports/MetricOptions.tsx b/apps/studio/components/interfaces/Reports/MetricOptions.tsx index 8ebe236af42..ffd5bd42433 100644 --- a/apps/studio/components/interfaces/Reports/MetricOptions.tsx +++ b/apps/studio/components/interfaces/Reports/MetricOptions.tsx @@ -1,17 +1,18 @@ import { useDebounce } from '@uidotdev/usehooks' -import { Home } from 'lucide-react' -import { useState } from 'react' - import { useParams } from 'common' +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useContentQuery } from 'data/content/content-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Metric, METRIC_CATEGORIES, METRICS } from 'lib/constants/metrics' +import { Home, Plus } from 'lucide-react' +import { useState } from 'react' +import { editorPanelState } from 'state/editor-panel-state' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import type { Dashboards } from 'types' import { Command_Shadcn_, - CommandEmpty_Shadcn_, CommandGroup_Shadcn_, CommandInput_Shadcn_, CommandItem_Shadcn_, @@ -24,6 +25,7 @@ import { SQL_ICON, } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import { DEPRECATED_REPORTS } from './Reports.constants' interface MetricOptionsProps { @@ -40,6 +42,7 @@ interface MetricOptionsProps { export const MetricOptions = ({ config, handleChartSelection }: MetricOptionsProps) => { const { ref: projectRef } = useParams() const { data: selectedOrganization } = useSelectedOrganizationQuery() + const { openSidebar } = useSidebarManagerSnapshot() const [search, setSearch] = useState('') const { projectAuthAll: authEnabled, projectStorageAll: storageEnabled } = useIsFeatureEnabled([ @@ -56,7 +59,11 @@ export const MetricOptions = ({ config, handleChartSelection }: MetricOptionsPro const { mutate: sendEvent } = useSendEventMutation() const debouncedSearch = useDebounce(search, 300) - const { data, isPending: isLoading } = useContentQuery({ + const { + data, + isPending: isLoading, + refetch, + } = useContentQuery({ projectRef, type: 'sql', name: debouncedSearch.length === 0 ? undefined : debouncedSearch, @@ -96,7 +103,11 @@ export const MetricOptions = ({ config, handleChartSelection }: MetricOptionsPro ) })} - + { + if (open) refetch() + }} + >
- ) : ( - No snippets found - )} + ) : !snippets?.length ? ( +

+ No snippets found +

+ ) : null} {snippets?.map((snippet) => ( + +
+ + + { + editorPanelState.openAsNew() + openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) + }} + > +
+ +

Create snippet

+
+
+
diff --git a/apps/studio/components/interfaces/Reports/Reports.tsx b/apps/studio/components/interfaces/Reports/Reports.tsx index 5a7afb238e9..ca74c461aef 100644 --- a/apps/studio/components/interfaces/Reports/Reports.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.tsx @@ -1,12 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useQueryClient } from '@tanstack/react-query' -import dayjs from 'dayjs' -import { groupBy, isEqual, isNull } from 'lodash' -import { ArrowRight, Plus, RefreshCw, Save } from 'lucide-react' -import { useRouter } from 'next/router' -import { DragEvent, useEffect, useState } from 'react' -import { toast } from 'sonner' - import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DatabaseSelector } from 'components/ui/DatabaseSelector' @@ -21,6 +14,7 @@ import { useContentUpsertMutation, } from 'data/content/content-upsert-mutation' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import dayjs from 'dayjs' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -28,10 +22,16 @@ import { BASE_PATH } from 'lib/constants' import { Metric, TIME_PERIODS_REPORTS } from 'lib/constants/metrics' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' +import { groupBy, isEqual, isNull } from 'lodash' +import { Plus, RefreshCw, Save } from 'lucide-react' +import { useRouter } from 'next/router' +import { DragEvent, useEffect, useState } from 'react' +import { toast } from 'sonner' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import type { Dashboards } from 'types' import { Button, cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, LogoLoader } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + import { createSqlSnippetSkeletonV2 } from '../SQLEditor/SQLEditor.utils' import { ChartConfig } from '../SQLEditor/UtilityPanel/ChartConfig' import { GridResize } from './GridResize' @@ -450,20 +450,6 @@ const Reports = () => {
} /> - - {startDate && endDate && ( -
- - {dayjs(startDate).format('MMM D, YYYY')} - - - - - - {dayjs(endDate).format('MMM D, YYYY')} - -
- )} diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 80a76d06d68..e403547c221 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -784,10 +784,10 @@ export const SQLEditor = () => {
- +
{isLoading ? (
@@ -898,7 +898,7 @@ export const SQLEditor = () => { - + {isLoading ? (
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx index 97e270265f8..73126157f46 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ChartConfig.tsx @@ -132,10 +132,10 @@ export const ChartConfig = ({ } return ( - - + + {!hasConfig ? ( - +
diff --git a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx index aa0d6a01e18..09c2fab36b3 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -393,14 +393,12 @@ export const LogTable = ({ return (
{!queryType && } - - + diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index 2e62aa4f5f1..96a4b519e33 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -23,6 +23,7 @@ import { ResizablePanel, ResizablePanelGroup, Separator, + useIsMobile, } from 'ui' import { RefreshButton } from '../../ui/DataTable/RefreshButton' @@ -312,6 +313,17 @@ export const UnifiedLogs = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [rowSelection, selectedRow, isLoading, isFetching]) + const isMobile = useIsMobile() + const [isFilterBarOpen, setIsFilterBarOpen] = useState(!isMobile) + + useEffect(() => { + if (isMobile) { + setIsFilterBarOpen(false) + } else { + setIsFilterBarOpen(true) + } + }, [isMobile]) + return ( { getFacetedUniqueValues={getFacetedUniqueValues(facets)} > - - - + + @@ -363,6 +374,8 @@ export const UnifiedLogs = () => { /> ) : null, ]} + isFilterBarOpen={isFilterBarOpen} + setIsFilterBarOpen={setIsFilterBarOpen} /> { /> - - - + + + }) => { <>
diff --git a/apps/studio/components/interfaces/UserDropdown.tsx b/apps/studio/components/interfaces/UserDropdown.tsx index 2a32c009cb1..4e7e7076908 100644 --- a/apps/studio/components/interfaces/UserDropdown.tsx +++ b/apps/studio/components/interfaces/UserDropdown.tsx @@ -54,7 +54,7 @@ export function UserDropdown() { {IS_PLATFORM && ( <>
- {!!username && ( + {!!username ? ( <> {username} @@ -68,6 +68,10 @@ export function UserDropdown() { )} + ) : ( + + {primaryEmail} + )}
diff --git a/apps/studio/components/layouts/DefaultLayout.tsx b/apps/studio/components/layouts/DefaultLayout.tsx index 01fd1703ffc..210d185d5b8 100644 --- a/apps/studio/components/layouts/DefaultLayout.tsx +++ b/apps/studio/components/layouts/DefaultLayout.tsx @@ -4,7 +4,7 @@ import { Sidebar } from 'components/interfaces/Sidebar' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useCheckLatestDeploy } from 'hooks/use-check-latest-deploy' import { useRouter } from 'next/router' -import { PropsWithChildren } from 'react' +import { PropsWithChildren, useEffect, useState } from 'react' import { useAppStateSnapshot } from 'state/app-state' import { ResizablePanel, ResizablePanelGroup, SidebarProvider } from 'ui' @@ -58,6 +58,18 @@ export const DefaultLayout = ({ const contentMinSizePercentage = 50 const contentMaxSizePercentage = 70 + const [isMounted, setIsMounted] = useState(false) + + useEffect(() => { + setIsMounted(true) + }, []) + + // This is required to prevent layout shift when rendering resizable panels (they initially render at 50%, then shift + // to whatever is specified). + if (!isMounted) { + return null + } + return ( @@ -82,25 +94,23 @@ export const DefaultLayout = ({ {!router.pathname.startsWith('/account') && } {/* Main Content with Layout Sidebar */}
{children}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx index bf579f21bd8..889e07bc124 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx @@ -104,7 +104,7 @@ describe('LayoutSidebar', () => { const renderSidebar = () => render( - +
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx index 51f1489705b..3e3161f0ce4 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.tsx @@ -4,17 +4,15 @@ import { ResizableHandle, ResizablePanel, cn } from 'ui' // Having these params as props as otherwise it's quite hard to visually check the sizes in DefaultLayout // as react resizeable panels requires all these values to be valid to render correctly interface LayoutSidebarProps { - order?: number - minSize?: number - maxSize?: number - defaultSize?: number + minSize?: string | number + maxSize?: string | number + defaultSize?: string | number } export const LayoutSidebar = ({ - order = 2, - minSize = 30, - maxSize = 50, - defaultSize = 30, + minSize = '30', + maxSize = '50', + defaultSize = '30', }: LayoutSidebarProps) => { const { activeSidebar } = useSidebarManagerSnapshot() @@ -26,7 +24,6 @@ export const LayoutSidebar = ({ @@ -133,45 +141,35 @@ export const ProjectLayout = forwardRef
- - {productMenu && ( + + {productMenu && sideBarIsOpen && ( - {sideBarIsOpen && ( - - + + - - - {productMenu} - - - - - )} + + {productMenu} + + + + )} {productMenu && sideBarIsOpen && ( @@ -182,12 +180,8 @@ export const ProjectLayout = forwardRef )}
void + onMount?: (editor: monacoEditor.IStandaloneCodeEditor, monaco: Monaco) => void } // [Joshen] This has overlap with components/interfaces/SQLEditor/MonacoEditor @@ -55,6 +56,7 @@ export const AIEditor = ({ closeShortcutEnabled = true, openAIAssistantShortcutEnabled = true, executeQuery, + onMount, }: AIEditorProps) => { const os = detectOS() const { toggleSidebar } = useSidebarManagerSnapshot() @@ -179,6 +181,7 @@ export const AIEditor = ({ ) => { editorRef.current = editor monacoRef.current = monaco + onMount?.(editor, monaco) // Set prompt state to open if promptInput exists if (promptInput) { const model = editor.getModel() diff --git a/apps/studio/components/ui/DataTable/DataTableToolbar.tsx b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx index b2e4bf7098a..8d6ce347177 100644 --- a/apps/studio/components/ui/DataTable/DataTableToolbar.tsx +++ b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx @@ -1,26 +1,30 @@ +import { useHotKey } from 'hooks/ui/useHotKey' import { PanelLeftClose, PanelLeftOpen } from 'lucide-react' import { ReactNode, useMemo } from 'react' - -import { useHotKey } from 'hooks/ui/useHotKey' import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + import { formatCompactNumber } from './DataTable.utils' import { DataTableFilterControlsDrawer } from './DataTableFilters/DataTableFilterControlsDrawer' import { DataTableResetButton } from './DataTableResetButton' import { DataTableViewOptions } from './DataTableViewOptions' import { Kbd } from './primitives/Kbd' -import { useControls } from './providers/ControlsProvider' import { useDataTable } from './providers/DataTableProvider' interface DataTableToolbarProps { renderActions?: () => ReactNode + isFilterBarOpen: boolean + setIsFilterBarOpen: React.Dispatch> } -export function DataTableToolbar({ renderActions }: DataTableToolbarProps) { +export function DataTableToolbar({ + renderActions, + isFilterBarOpen, + setIsFilterBarOpen, +}: DataTableToolbarProps) { const { table, isLoading, columnFilters } = useDataTable() - const { open, setOpen } = useControls() const filters = table.getState().columnFilters - useHotKey(() => setOpen((prev) => !prev), 'b') + useHotKey(() => setIsFilterBarOpen((prev) => !prev), 'b') const rows = useMemo( () => ({ @@ -38,11 +42,11 @@ export function DataTableToolbar({ renderActions }: DataTableToolbarProps) { diff --git a/apps/studio/components/ui/DataTable/FilterSideBar.tsx b/apps/studio/components/ui/DataTable/FilterSideBar.tsx index 0811399f464..1797cb30cca 100644 --- a/apps/studio/components/ui/DataTable/FilterSideBar.tsx +++ b/apps/studio/components/ui/DataTable/FilterSideBar.tsx @@ -1,8 +1,8 @@ import { useFlag, useParams } from 'common' import Link from 'next/link' import { useRouter } from 'next/router' -import React from 'react' -import { Button, cn, ResizablePanel } from 'ui' +import React, { useEffect } from 'react' +import { Button, cn, ResizablePanel, usePanelRef } from 'ui' import { FeaturePreviewSidebarPanel } from '../FeaturePreviewSidebarPanel' import { DateRangeDisabled } from './DataTable.types' @@ -13,10 +13,16 @@ import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePrevie import { LOG_DRAIN_TYPES } from '@/components/interfaces/LogDrains/LogDrains.constants' interface FilterSideBarProps { + isFilterBarOpen: boolean + setIsFilterBarOpen: React.Dispatch> dateRangeDisabled?: DateRangeDisabled } -export function FilterSideBar({ dateRangeDisabled }: FilterSideBarProps) { +export function FilterSideBar({ + isFilterBarOpen, + setIsFilterBarOpen, + dateRangeDisabled, +}: FilterSideBarProps) { const router = useRouter() const { ref } = useParams() const { table } = useDataTable() @@ -29,18 +35,31 @@ export function FilterSideBar({ dateRangeDisabled }: FilterSideBarProps) { router.push(`/project/${ref}/logs/explorer`) } + const panelRef = usePanelRef() + + useEffect(() => { + if (isFilterBarOpen) { + panelRef.current?.expand() + } else { + panelRef.current?.collapse() + } + }, [isFilterBarOpen, panelRef]) + return ( { + if (size.inPixels === 0) { + setIsFilterBarOpen(false) + } else if (!isFilterBarOpen) { + setIsFilterBarOpen(true) + } + }} + className={cn('flex flex-col w-full')} >
diff --git a/apps/studio/components/ui/DataTable/providers/ControlsProvider.tsx b/apps/studio/components/ui/DataTable/providers/ControlsProvider.tsx deleted file mode 100644 index ccdf799e8f1..00000000000 --- a/apps/studio/components/ui/DataTable/providers/ControlsProvider.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useLocalStorage } from 'hooks/misc/useLocalStorage' -import { createContext, Dispatch, SetStateAction, useContext } from 'react' - -interface ControlsContextType { - open: boolean - setOpen: Dispatch> -} - -export const ControlsContext = createContext(null) - -export function ControlsProvider({ children }: { children: React.ReactNode }) { - const [open, setOpen] = useLocalStorage('data-table-controls', true) - - return ( - -
- {children} -
-
- ) -} - -export function useControls() { - const context = useContext(ControlsContext) - - if (!context) { - throw new Error('useControls must be used within a ControlsProvider') - } - - return context as ControlsContextType -} diff --git a/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx b/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx index 2515c748a59..7a49998c1a8 100644 --- a/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx +++ b/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx @@ -7,11 +7,10 @@ import type { Table, VisibilityState, } from '@tanstack/react-table' +import { QuerySearchParamsType } from 'components/interfaces/UnifiedLogs/UnifiedLogs.types' import { createContext, ReactNode, useContext, useMemo } from 'react' -import { QuerySearchParamsType } from 'components/interfaces/UnifiedLogs/UnifiedLogs.types' import { DataTableFilterField } from '../DataTable.types' -import { ControlsProvider } from './ControlsProvider' // REMINDER: read about how to move controlled state out of the useReactTable hook // https://github.com/TanStack/table/discussions/4005#discussioncomment-7303569 @@ -66,11 +65,7 @@ export function DataTableProvider({ [props] ) - return ( - - {children} - - ) + return {children} } export function useDataTable() { diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx index 7f660ea02c1..a9a4ce4548a 100644 --- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx @@ -1,3 +1,6 @@ +import type { Monaco } from '@monaco-editor/react' +import { useQueryClient } from '@tanstack/react-query' +import { useDebounce } from '@uidotdev/usehooks' import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { isExplainQuery } from 'components/interfaces/ExplainVisualizer/ExplainVisualizer.utils' import { generateSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor.constants' @@ -5,19 +8,33 @@ import { createSqlSnippetSkeletonV2, suffixWithLimit, } from 'components/interfaces/SQLEditor/SQLEditor.utils' +import { useAddDefinitions } from 'components/interfaces/SQLEditor/useAddDefinitions' import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' import { SqlRunButton } from 'components/interfaces/SQLEditor/UtilityPanel/RunButton' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { useContentIdQuery } from 'data/content/content-id-query' +import { useContentQuery, type Content } from 'data/content/content-query' +import { useContentUpsertMutation } from 'data/content/content-upsert-mutation' +import { contentKeys } from 'data/content/keys' import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { BASE_PATH } from 'lib/constants' import { useProfile } from 'lib/profile' -import { Book, Maximize2, X } from 'lucide-react' +import { + AlertCircle, + Book, + CheckCircle2, + FolderOpen, + Loader2, + Maximize2, + PlusIcon, + X, +} from 'lucide-react' import { useRouter } from 'next/router' -import { useId, useState } from 'react' -import { useEditorPanelStateSnapshot } from 'state/editor-panel-state' +import { useEffect, useRef, useState } from 'react' +import { editorPanelState, useEditorPanelStateSnapshot } from 'state/editor-panel-state' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { @@ -45,6 +62,8 @@ import { containsUnknownFunction, isReadOnlySelect } from '../AIAssistantPanel/A import { AIEditor } from '../AIEditor' import { ButtonTooltip } from '../ButtonTooltip' import { SqlWarningAdmonition } from '../SqlWarningAdmonition' +import { formatSqlError } from './EditorPanel.utils' +import { SaveSnippetDialog } from './SaveSnippetDialog' export const EditorPanel = () => { const { @@ -58,12 +77,33 @@ export const EditorPanel = () => { setTemplates, setResults, setError, + activeSnippetId, + pendingReset, } = useEditorPanelStateSnapshot() const { profile } = useProfile() const { closeSidebar } = useSidebarManagerSnapshot() const sqlEditorSnap = useSqlEditorV2StateSnapshot() + const queryClient = useQueryClient() - const label = 'SQL Editor' + const [activeSnippet, setActiveSnippet] = useState | null>(null) + const [isEditingTitle, setIsEditingTitle] = useState(false) + const [titleInput, setTitleInput] = useState('') + const titleInputRef = useRef(null) + const [monaco, setMonaco] = useState(null) + + useAddDefinitions('', monaco) + + const label = activeSnippet?.name ?? 'SQL Editor' + + const commitRename = () => { + const newName = titleInput.trim() + if (!newName || !activeSnippet) { + setIsEditingTitle(false) + return + } + setActiveSnippet({ ...activeSnippet, name: newName }) + setIsEditingTitle(false) + } const [isInlineEditorHotkeyEnabled] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(SIDEBAR_KEYS.EDITOR_PANEL), true @@ -82,17 +122,63 @@ export const EditorPanel = () => { const [showWarning, setShowWarning] = useState<'hasWriteOperation' | 'hasUnknownFunctions'>() const [showResults, setShowResults] = useState(true) - const [isTemplatesOpen, setIsTemplatesOpen] = useState(false) - const templatesListboxId = useId() + const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false) + const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle') + const saveStatusTimerRef = useRef>() + const originalSnippetRef = useRef<{ sql: string; name: string } | null>(null) - const errorHeader = error?.formattedError?.split('\n')?.filter((x: string) => x.length > 0)?.[0] - const errorContent = - 'formattedError' in (error || {}) - ? error?.formattedError - ?.split('\n') - ?.filter((x: string) => x.length > 0) - ?.slice(1) ?? [] - : [error?.message ?? ''] + const showSaveSuccess = () => { + setSaveStatus('success') + clearTimeout(saveStatusTimerRef.current) + saveStatusTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000) + } + const [isTemplatesOpen, setIsTemplatesOpen] = useState(false) + const [isSnippetsOpen, setIsSnippetsOpen] = useState(false) + const [snippetSearch, setSnippetSearch] = useState('') + const debouncedSnippetSearch = useDebounce(snippetSearch, 300) + + const { data: snippetsData, isLoading: isLoadingSnippets } = useContentQuery( + { projectRef: ref, type: 'sql', name: debouncedSnippetSearch || undefined }, + { enabled: isSnippetsOpen } + ) + + const { data: snippetById } = useContentIdQuery( + { projectRef: ref, id: activeSnippetId ?? undefined }, + { enabled: !!activeSnippetId } + ) + + useEffect(() => { + if (!pendingReset) return + setActiveSnippet(null) + setIsEditingTitle(false) + originalSnippetRef.current = null + editorPanelState.pendingReset = false + }, [pendingReset, setActiveSnippet, setIsEditingTitle]) + + useEffect(() => { + if (!snippetById || !activeSnippetId) return + const sqlSnippet = snippetById as unknown as Extract + const sql = sqlSnippet.content.sql ?? '' + setValue(sql) + setActiveSnippet(sqlSnippet) + originalSnippetRef.current = { sql, name: sqlSnippet.name } + editorPanelState.setActiveSnippetId(null) + }, [snippetById, activeSnippetId, setValue, setActiveSnippet]) + + const { header: errorHeader, lines: errorContent } = error + ? formatSqlError(error) + : { header: undefined, lines: [] as string[] } + + const { mutate: upsertContent, isPending: isUpserting } = useContentUpsertMutation({ + onSuccess: (_, vars) => { + if (vars.payload.id && ref) { + queryClient.invalidateQueries({ queryKey: contentKeys.resource(ref, vars.payload.id) }) + } + originalSnippetRef.current = { sql: currentValue, name: vars.payload.name } + showSaveSuccess() + }, + onError: () => setSaveStatus('error'), + }) const { mutate: executeSql, isPending: isExecuting } = useExecuteSqlMutation({ onSuccess: async (res) => { @@ -152,13 +238,102 @@ export const EditorPanel = () => { setError(undefined) setShowWarning(undefined) setShowResults(true) + setActiveSnippet(null) + setIsEditingTitle(false) + editorPanelState.setActiveSnippetId(null) } return (
-
{label}
-
+ {isEditingTitle ? ( + setTitleInput(e.target.value)} + onBlur={commitRename} + onKeyDown={(e) => { + if (e.key === 'Enter') commitRename() + if (e.key === 'Escape') setIsEditingTitle(false) + }} + className="text-xs bg-transparent border-b border-foreground-lighter outline-none w-48 py-0.5" + autoFocus + /> + ) : ( +
{ + if (!activeSnippet) return + setTitleInput(activeSnippet.name) + setIsEditingTitle(true) + }} + > + {label} +
+ )} +
+ {activeSnippet && ( + } + tooltip={{ content: { side: 'bottom', text: 'New snippet' } }} + onClick={() => editorPanelState.openAsNew()} + /> + )} + + + } + tooltip={{ + content: { + side: 'bottom', + text: 'Open snippet', + }, + }} + > + + + + + + {isLoadingSnippets ? ( +
+ Loading snippets... +
+ ) : ( + No snippets found. + )} + + {(snippetsData?.content ?? []).map((snippet) => ( + { + if (snippet.id) editorPanelState.setActiveSnippetId(snippet.id) + setIsSnippetsOpen(false) + setSnippetSearch('') + }} + > + {snippet.name} + + ))} + +
+
+
+
{templates.length > 0 && ( @@ -168,13 +343,12 @@ export const EditorPanel = () => { role="combobox" className="mr-2" aria-expanded={isTemplatesOpen} - aria-controls={templatesListboxId} icon={} > Templates - + @@ -284,6 +458,7 @@ export const EditorPanel = () => { language="pgsql" value={currentValue} onChange={handleChange} + onMount={(_, m) => setMonaco(m)} aiEndpoint={`${BASE_PATH}/api/ai/code/complete`} aiMetadata={{ projectRef: project?.ref, @@ -379,10 +554,89 @@ export const EditorPanel = () => {
)} -
+
+ {(isUpserting || saveStatus !== 'idle') && ( +
+ {isUpserting && } + {saveStatus === 'success' && } + {saveStatus === 'error' && } + + {isUpserting + ? 'Saving...' + : saveStatus === 'success' + ? 'Snippet updated' + : 'Failed to save snippet'} + +
+ )} +
+ { + if (!ref || !profile || !project) return + const snippet = createSqlSnippetSkeletonV2({ + name, + sql: currentValue, + owner_id: profile.id, + project_id: project.id, + }) + sqlEditorSnap.addSnippet({ projectRef: ref, snippet }) + sqlEditorSnap.addNeedsSaving(snippet.id) + setActiveSnippet(snippet as unknown as Extract) + originalSnippetRef.current = { sql: currentValue, name } + showSaveSuccess() + }} + />
) } diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.utils.ts b/apps/studio/components/ui/EditorPanel/EditorPanel.utils.ts new file mode 100644 index 00000000000..3a6ffa836c7 --- /dev/null +++ b/apps/studio/components/ui/EditorPanel/EditorPanel.utils.ts @@ -0,0 +1,9 @@ +import type { SqlError } from 'state/editor-panel-state' + +export function formatSqlError(error: SqlError): { header: string | undefined; lines: string[] } { + if (error.formattedError) { + const lines = error.formattedError.split('\n').filter((l) => l.length > 0) + return { header: lines[0], lines: lines.slice(1) } + } + return { header: undefined, lines: [error.message ?? ''] } +} diff --git a/apps/studio/components/ui/EditorPanel/SaveSnippetDialog.tsx b/apps/studio/components/ui/EditorPanel/SaveSnippetDialog.tsx new file mode 100644 index 00000000000..4412fbf4604 --- /dev/null +++ b/apps/studio/components/ui/EditorPanel/SaveSnippetDialog.tsx @@ -0,0 +1,123 @@ +import { useParams } from 'common' +import { subscriptionHasHipaaAddon } from 'components/interfaces/Billing/Subscription/Subscription.utils' +import { generateSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor.constants' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query' +import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' +import { + AiIconAnimation, + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + Input_Shadcn_, + Label_Shadcn_, +} from 'ui' + +interface SaveSnippetDialogProps { + open: boolean + sql: string + onOpenChange: (open: boolean) => void + onSave: (name: string) => void +} + +export const SaveSnippetDialog = ({ open, sql, onOpenChange, onSave }: SaveSnippetDialogProps) => { + const { ref } = useParams() + const { data: organization } = useSelectedOrganizationQuery() + const { data: subscription } = useOrgSubscriptionQuery( + { orgSlug: organization?.slug }, + { enabled: open } + ) + const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: ref }) + const { data: check } = useCheckOpenAIKeyQuery() + + const [name, setName] = useState(generateSnippetTitle()) + + const isApiKeySet = !!check?.hasKey + const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) && projectSettings?.is_sensitive + + const { mutate: generateTitle, isPending: isGenerating } = useSqlTitleGenerateMutation({ + onSuccess: ({ title }) => setName(title), + onError: (error) => toast.error(`Failed to generate title: ${error.message}`), + }) + + // Reset the name each time the dialog opens + useEffect(() => { + if (open) setName(generateSnippetTitle()) + }, [open]) + + const handleSave = () => { + const trimmed = name.trim() + if (!trimmed) return + onSave(trimmed) + onOpenChange(false) + } + + return ( + + + + Save snippet + + + +
+ Name + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave() + }} + /> +
+ {!hasHipaaAddon && ( +
+ generateTitle({ sql })} + tooltip={{ + content: { + side: 'bottom', + text: isApiKeySet + ? undefined + : 'Add your "OPENAI_API_KEY" to your environment variables to use this feature.', + }, + }} + > +
+
+ +
+ Generate with AI +
+
+
+ )} +
+ + + + + +
+
+ ) +} diff --git a/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx b/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx index 5e514a60979..2664e35f972 100644 --- a/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx +++ b/apps/studio/components/ui/QueryBlock/EditQueryButton.tsx @@ -1,15 +1,14 @@ -import { Edit } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { ComponentProps } from 'react' - import { useParams } from 'common' import { useIsInlineEditorEnabled } from 'components/interfaces/Account/Preferences/InlineEditorSettings' -import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' import useNewQuery from 'components/interfaces/SQLEditor/hooks' +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { Edit } from 'lucide-react' +import { useRouter } from 'next/router' +import { ComponentProps } from 'react' +import { editorPanelState } from 'state/editor-panel-state' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { @@ -20,6 +19,7 @@ import { DropdownMenuTrigger, TooltipContent, } from 'ui' + import { ButtonTooltip } from '../ButtonTooltip' interface EditQueryButtonProps { @@ -42,7 +42,7 @@ export const EditQueryButton = ({ const { newQuery } = useNewQuery() const sqlEditorSnap = useSqlEditorV2StateSnapshot() - const { closeSidebar } = useSidebarManagerSnapshot() + const { closeSidebar, openSidebar } = useSidebarManagerSnapshot() const isInSQLEditor = router.pathname.includes('/sql') const isInNewSnippet = router.pathname.endsWith('/sql') @@ -57,15 +57,16 @@ export const EditQueryButton = ({ if (id !== undefined) { return ( } tooltip={tooltip} - > - - + onClick={() => { + editorPanelState.setActiveSnippetId(id) + openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) + }} + /> ) } diff --git a/apps/studio/data/table-rows/table-rows-query.ts b/apps/studio/data/table-rows/table-rows-query.ts index 8332f6929cc..12e72673096 100644 --- a/apps/studio/data/table-rows/table-rows-query.ts +++ b/apps/studio/data/table-rows/table-rows-query.ts @@ -395,10 +395,13 @@ export const useTableRowsQuery = ( ) => { const queryClient = useQueryClient() + // [Joshen] Exclude preflightCheck from query key + const { preflightCheck, ...othersArgs } = args + return useQuery({ queryKey: tableRowKeys.tableRows(projectRef, { table: { id: tableId }, - ...args, + ...othersArgs, }), queryFn: ({ signal }) => getTableRows({ queryClient, projectRef, connectionString, tableId, ...args }, signal), diff --git a/apps/studio/package.json b/apps/studio/package.json index e95f81597a0..f12464045b2 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -82,9 +82,9 @@ "cron-parser": "^4.9.0", "cronstrue": "^2.50.0", "crypto-js": "^4.2.0", - "dev-tools": "workspace:*", "d3-geo": "^3.1.1", "dayjs": "^1.11.10", + "dev-tools": "workspace:*", "dnd-core": "^16.0.1", "file-saver": "^2.0.5", "framer-motion": "^11.11.17", @@ -190,6 +190,7 @@ "date-fns": "^2.30.0", "eslint-config-supabase": "workspace:*", "eslint-plugin-barrel-files": "^2.0.7", + "eslint-plugin-jsx-a11y": "^6.10.2", "graphql-ws": "5.14.1", "import-in-the-middle": "^2.0.0", "jsdom-testing-mocks": "^1.13.1", diff --git a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx index 68b1b8bc0ae..96b0fd6a934 100644 --- a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -112,12 +112,12 @@ const PITR = () => { ) : !isActiveHealthy ? ( diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index 93ca9663537..f0d6a7c6387 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -378,10 +378,10 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
- + { /> - + request.nextUrl.pathname.endsWith(url)) - ) { - return Response.json( - { success: false, message: 'Endpoint not supported on hosted' }, - { status: 404 } - ) + // API route filtering for hosted platform + if (request.nextUrl.pathname.startsWith('/api/')) { + if ( + IS_PLATFORM && + !HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url)) + ) { + return Response.json( + { success: false, message: 'Endpoint not supported on hosted' }, + { status: 404 } + ) + } } + + const response = NextResponse.next() + stampFirstReferrerCookie(request, response) + return response +} + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|__nextjs).*)'], } diff --git a/apps/studio/state/app-state.ts b/apps/studio/state/app-state.ts index d4533c5462e..aed3c6826e8 100644 --- a/apps/studio/state/app-state.ts +++ b/apps/studio/state/app-state.ts @@ -1,6 +1,5 @@ -import { proxy, snapshot, useSnapshot } from 'valtio' - import { LOCAL_STORAGE_KEYS as COMMON_LOCAL_STORAGE_KEYS } from 'common' +import { proxy, snapshot, useSnapshot } from 'valtio' const getInitialState = () => { return { diff --git a/apps/studio/state/editor-panel-state.tsx b/apps/studio/state/editor-panel-state.tsx index 167e33ef3b5..bff9268b6e8 100644 --- a/apps/studio/state/editor-panel-state.tsx +++ b/apps/studio/state/editor-panel-state.tsx @@ -6,13 +6,21 @@ type Template = { content: string } +export type SqlError = { + error?: string + formattedError?: string + message?: string +} + type EditorPanelState = { value: string templates: Template[] - results: any[] | undefined - error: any + results: Record[] | undefined + error: SqlError | undefined initialPrompt: string onChange: ((value: string) => void) | undefined + activeSnippetId: string | null + pendingReset: boolean } const initialState: EditorPanelState = { @@ -22,6 +30,8 @@ const initialState: EditorPanelState = { error: undefined, initialPrompt: '', onChange: undefined, + activeSnippetId: null, + pendingReset: false, } export const editorPanelState = proxy({ @@ -35,15 +45,24 @@ export const editorPanelState = proxy({ setTemplates(templates: Template[]) { editorPanelState.templates = templates }, - setResults(results: any[] | undefined) { + setResults(results: Record[] | undefined) { editorPanelState.results = results }, - setError(error: any) { + setError(error: SqlError | undefined) { editorPanelState.error = error }, setInitialPrompt(initialPrompt: string) { editorPanelState.initialPrompt = initialPrompt }, + setActiveSnippetId(id: string | null) { + editorPanelState.activeSnippetId = id + }, + openAsNew() { + editorPanelState.value = '' + editorPanelState.results = undefined + editorPanelState.error = undefined + editorPanelState.pendingReset = true + }, reset() { Object.assign(editorPanelState, initialState) }, diff --git a/apps/studio/tests/setup/polyfills.ts b/apps/studio/tests/setup/polyfills.ts index e37e5482f5a..ba913603e4c 100644 --- a/apps/studio/tests/setup/polyfills.ts +++ b/apps/studio/tests/setup/polyfills.ts @@ -1,8 +1,8 @@ -import { TextDecoder, TextEncoder } from 'node:util' import { ReadableStream, TransformStream } from 'node:stream/web' -import { vi } from 'vitest' -import { configMocks } from 'jsdom-testing-mocks' +import { TextDecoder, TextEncoder } from 'node:util' import { act } from '@testing-library/react' +import { configMocks } from 'jsdom-testing-mocks' +import { vi } from 'vitest' configMocks({ act }) diff --git a/apps/www/README.md b/apps/www/README.md index de8fd244143..f2f7df15d90 100644 --- a/apps/www/README.md +++ b/apps/www/README.md @@ -130,6 +130,42 @@ og_image: /images/events/2025-01-meetup/custom-og.png # Optional override **Note**: The `og_image` field is optional. If not provided, OG images are generated automatically via the Edge Function. +## Go pages (`/go/*`) + +`/go/` is a system for building standalone campaign landing pages (lead generation, legal, thank-you flows). The name is intentionally generic — these pages are typically linked from ads, emails, or partner campaigns and are not part of the main site navigation. + +Pages are defined as TypeScript objects (not MDX files) and validated against Zod schemas at build time. + +### Parts + +| Location | Purpose | +| ------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `apps/www/_go/` | Page definitions. Each file exports a page object. `index.tsx` registers all pages. | +| `apps/www/app/go/[slug]/page.tsx` | App Router route — renders the page for a given slug, handles 404s and metadata. | +| `apps/www/components/Go/GoPageRenderer.tsx` | www-specific wrapper — adds the Supabase logo header and footer, registers custom section renderers. | +| `packages/marketing/src/go/` | Framework-agnostic core: schemas, section components, templates, form server action. | +| `packages/marketing/src/crm/` | CRM client abstraction (HubSpot + Customer.io) used by the form server action. | + +### Page structure + +Each page specifies a `template` which determines its top-level layout: + +- **`lead-gen`** — hero + arbitrary sections (form, metrics, feature grid, tweets, social proof, etc.) +- **`thank-you`** — hero + sections + confetti animation +- **`legal`** — hero + table-of-contents sidebar + markdown body + +Pages are arrays of typed section objects. The `SectionRenderer` in `packages/marketing` dispatches each section to the right component based on its `type` field. + +### Custom renderers + +The `marketing` package doesn't know about `topTweets` data or the Pages Router `basePath`, so the `tweets` section type has no default renderer. `GoPageRenderer.tsx` in www registers `TweetsSection` as a custom renderer for that type. This is the extension point for any section that requires www-specific dependencies. + +### Adding a new page + +1. Create a new file in `apps/www/_go//my-page.tsx` exporting a page object. +2. Register it in `apps/www/_go/index.tsx`. +3. The page will be available at `/go/` automatically via static generation. + #### Customer Stories (Case Studies) Customer stories are defined in MDX files (`apps/www/_customers/*.mdx`) and use a different approach: diff --git a/apps/www/_go/index.tsx b/apps/www/_go/index.tsx new file mode 100644 index 00000000000..6faa297aba1 --- /dev/null +++ b/apps/www/_go/index.tsx @@ -0,0 +1,9 @@ +import type { GoPageInput } from 'marketing' + +import exampleLeadGen from './lead-gen/example-lead-gen' +import exampleLegal from './legal/example-legal' +import exampleThankYou from './thank-you/example-thank-you' + +const pages: GoPageInput[] = [exampleLeadGen, exampleThankYou, exampleLegal] + +export default pages diff --git a/apps/www/_go/lead-gen/example-lead-gen.tsx b/apps/www/_go/lead-gen/example-lead-gen.tsx new file mode 100644 index 00000000000..ec6fa623ea9 --- /dev/null +++ b/apps/www/_go/lead-gen/example-lead-gen.tsx @@ -0,0 +1,148 @@ +import { MediaBlock } from 'marketing' +import type { GoPageInput } from 'marketing' + +const page: GoPageInput = { + template: 'lead-gen', + slug: 'example-ebook', + metadata: { + title: 'Free Ebook: Building Modern Applications with Supabase', + description: + 'Download our comprehensive guide to building scalable applications with Supabase. Learn best practices for authentication, database design, and real-time features.', + ogImage: '/images/landing-pages/example-ebook/og.png', + }, + hero: { + title: 'Building Modern Applications with Supabase', + subtitle: 'Sample ebook landing page', + description: + 'This is a sample lead generation page. The content below demonstrates the template layout. Replace it with your real ebook title, description, and cover image.', + image: { + src: 'https://zhfonblqamxferhoguzj.supabase.co/functions/v1/generate-og?template=announcement&layout=vertical©=Modern+applications&icon=supabase.svg', + alt: 'Ebook cover: Building Modern Applications with Supabase', + width: 400, + height: 500, + }, + ctas: [ + { + label: 'Download Free Ebook', + href: '#form', + variant: 'primary', + }, + ], + }, + sections: [ + { + type: 'single-column', + title: 'See Supabase in action', + description: 'Watch a quick overview of what you can build with Supabase.', + children: , + }, + { + type: 'feature-grid', + title: 'Developers can build faster with Supabase', + description: 'Features that help developers move quickly and focus.', + items: [ + { + title: 'AI Assistant', + description: + 'A single panel that persists across the Supabase Dashboard and maintains context across AI prompts.', + }, + { + title: 'MCP Server', + description: + 'Connect your favorite AI tools such as Cursor or Claude directly with Supabase.', + }, + { + title: 'Auto-generated APIs', + description: + "Learn SQL when you're ready. In the meantime, Supabase generates automatic APIs to make coding a lot easier.", + }, + { + title: 'Foreign Data Wrappers', + description: + 'Connect Supabase to Redshift, BigQuery, MySQL, and external APIs for seamless integrations.', + }, + { + title: 'Instant and secure deployment', + description: 'No need to set up servers, manage DevOps, or tweak security settings.', + }, + { + title: 'Observability', + description: + 'Built-in logs, query performance tools, and security insights for easy debugging.', + }, + ], + }, + { + type: 'metrics', + items: [ + { label: 'Databases created', value: '16,000,000+' }, + { label: 'Databases launched daily', value: '90,000+' }, + { label: 'GitHub stars', value: '80,000+' }, + ], + }, + { + type: 'tweets', + title: 'Loved by developers', + description: 'Discover what our community has to say about their Supabase experience.', + ctas: [ + { + label: 'Start your project', + href: 'https://supabase.com/dashboard', + variant: 'primary', + }, + ], + }, + { + type: 'form', + title: 'Get in touch', + description: 'Fill out the form below and our team will get back to you shortly.', + fields: [ + { + type: 'text', + name: 'firstName', + label: 'First Name', + placeholder: 'First Name', + required: true, + half: true, + }, + { + type: 'text', + name: 'lastName', + label: 'Last Name', + placeholder: 'Last Name', + required: true, + half: true, + }, + { + type: 'email', + name: 'email', + label: 'Company Email', + placeholder: 'Company Email', + required: true, + }, + { + type: 'textarea', + name: 'interest', + label: 'What are you interested in?', + placeholder: 'Share more about what you want to accomplish', + }, + ], + submitLabel: 'Request a demo', + disclaimer: + 'By submitting this form, I confirm that I have read and understood the [Privacy Policy](https://supabase.com/privacy).', + crm: { + hubspot: { + formGuid: 'b9abbf77-86ae-4fe7-9147-d15922bf58ca', + fieldMap: { + firstName: 'firstname', + lastName: 'lastname', + }, + consent: + 'By submitting this form, I confirm that I have read and understood the Privacy Policy.', + }, + }, + }, + ], +} + +export default page diff --git a/apps/www/_go/legal/example-legal.tsx b/apps/www/_go/legal/example-legal.tsx new file mode 100644 index 00000000000..d25d4f1113a --- /dev/null +++ b/apps/www/_go/legal/example-legal.tsx @@ -0,0 +1,149 @@ +import type { GoPageInput } from 'marketing' + +const page: GoPageInput = { + template: 'legal', + slug: 'example-legal', + metadata: { + title: 'Supabase Official Rules For Giveaways & Sweepstakes', + description: + 'NO PURCHASE OR PAYMENT NECESSARY TO ENTER OR WIN. A PURCHASE OR PAYMENT WILL NOT INCREASE YOUR CHANCES OF WINNING. VOID WHERE PROHIBITED.', + }, + hero: { + title: 'Supabase Official Rules For Giveaways & Sweepstakes', + }, + effectiveDate: '2026-02-16', + body: ` + ## 1. Sponsor + + Supabase, Inc. ("Sponsor"). + + ## 2. Promotion Period + + The Promotion begins at the time and date announced at the applicable Supabase event or digital campaign and ends at the time specified in the Promotion materials (the "Promotion Period"). Sponsor's system is the official time-keeping device. + + ## 3. Eligibility + + The Promotion is open to legal residents of countries where such promotions are permitted by law, who are at least the age of majority in their jurisdiction of residence at the time of entry. + + The following are not eligible to enter or win: + + - Employees, officers, and directors of Sponsor and its affiliates + - Contractors, marketing agencies, and vendors involved in administering the Promotion + - Immediate family members and household members of the above + + The Promotion is void in jurisdictions where sweepstakes are prohibited or materially restricted without filings, bonding, or government approvals, including but not limited to: + + - Brazil + - Quebec (Canada) + - Italy + - Spain + - Russia + - Iran + - North Korea + - Syria + - Cuba + - Belarus + - Mainland China + - Crimea, Donetsk, and Luhansk regions of Ukraine + - Any jurisdiction subject to U.S., EU, or UN trade sanctions + - Any jurisdiction requiring registration, bonding, or regulatory approvals not obtained by Sponsor + + Sponsor reserves the right to disqualify any entrant whose participation is unlawful or non-compliant under local law. + + ## 4. How to Enter + + To enter, follow the instructions provided at the applicable Supabase event or promotional campaign, which may include: + + - Scanning a badge at a Supabase booth + - Submitting an entry form + - Creating a Supabase account + - Completing a product action (e.g., launching a project, loading data) + + Limit: One (1) entry per person unless otherwise specified. + + Entries must be received during the Promotion Period. + + Automated, robotic, or scripted entries are prohibited. + + ## 5. Prizes + + Prizes will be described in the Promotion materials and may include consumer electronics, merchandise, credits, or other items of value. Approximate Retail Value ("ARV") will be disclosed for each Promotion. Sponsor reserves the right to substitute a prize of equal or greater value if the advertised prize becomes unavailable or cannot be legally shipped or awarded in a winner's jurisdiction. + + ## 6. Winner Selection & Notification + + Winners will be selected in a random drawing or other announced method at the time specified in the Promotion materials. Winners will be notified via the contact information provided at entry. Failure to respond within seven (7) days of notification may result in forfeiture, and an alternate winner may be selected. + + ## 7. International Winners -- Taxes, Customs, and Duties + + Winners are solely responsible for all taxes, customs duties, VAT, import fees, brokerage fees, and any other charges imposed by local authorities. Sponsor may require winners to complete tax forms or declarations as required by law. Sponsor will not be responsible for prizes delayed, seized, or rejected by customs or local authorities. If a prize cannot be legally delivered to a winner's country, Sponsor may substitute a prize of equal or greater value or award a cash or credit equivalent, at Sponsor's discretion. + + ## 8. Prize Substitution & Local Compliance + + Sponsor reserves the right to: + + - Substitute prizes based on local availability or legal restrictions + - Modify the prize format (e.g., cash, store credit, digital gift card) for international winners + - Disqualify entries from jurisdictions where the Promotion cannot be lawfully administered + + ## 9. Odds + + Odds of winning depend on the number of eligible entries received. + + ## 10. Publicity + + Except where prohibited by law, participation constitutes winner's consent to Sponsor's use of winner's name, likeness, city, country, and prize information for promotional purposes without additional compensation. + + ## 11. Privacy + + Personal information collected in connection with the Promotion will be used in accordance with Sponsor's [Privacy Policy](https://supabase.com/privacy). + + ## 12. Limitation of Liability + + Sponsor is not responsible for: + + - Lost, late, incomplete, or misdirected entries + - Technical failures of any kind + - Unauthorized human intervention + - Errors in the administration of the Promotion + + By entering, entrants release and hold harmless Sponsor from any claims arising out of participation or prize acceptance. + + ## 13. Disputes & Governing Law + + All disputes shall be governed by the laws of the State of California, USA, without regard to conflict-of-law principles. Any dispute, claim, or controversy arising out of or relating to this Promotion shall be resolved by binding arbitration administered by the American Arbitration Association under its Commercial Arbitration Rules. All claims shall be resolved individually, without class actions. The arbitration shall be conducted in San Francisco, California. The arbitrator's decision shall be final and binding. + + ## 14. Sanctions & Trade Compliance + + Entrants represent and warrant that they are not located in, under the control of, or a resident of any country or territory subject to U.S., EU, or UN sanctions and are not listed on any government sanctions or restricted party lists. + + Sponsor reserves the right to disqualify any entrant or withhold any prize if awarding such prize would violate applicable trade or sanctions laws. + + ## 15. Data Transfers -- GDPR / UK GDPR + + By entering, entrants consent to the transfer, processing, and storage of their personal data in the United States and other jurisdictions for purposes of administering the Promotion, selecting winners, awarding prizes, and related promotional activities. + + ## 16. Force Majeure / Regulatory Impossibility + + Sponsor shall not be responsible for any failure or delay in performance due to events beyond its reasonable control, including but not limited to acts of God, war, terrorism, labor disputes, governmental orders, regulatory changes, public health emergencies, shipping disruptions, or technical failures. + + Sponsor reserves the right to modify, suspend, or cancel the Promotion if regulatory changes or governmental restrictions make lawful administration impracticable. + + ## 17. General Conditions + + Sponsor reserves the right to cancel, modify, or suspend the Promotion if fraud, technical failure, or any other factor impairs the integrity of the Promotion. Sponsor's decisions are final. + + ## 18. Winners List + + For a copy of the winners list, send a request to: + + [legal@supabase.com](mailto:legal@supabase.com) + + within sixty (60) days of the end of the Promotion. + + © 2026 Supabase Inc. + + [Privacy Policy](https://supabase.com/privacy) · [Terms of Service](https://supabase.com/terms) +`, +} + +export default page diff --git a/apps/www/_go/thank-you/example-thank-you.tsx b/apps/www/_go/thank-you/example-thank-you.tsx new file mode 100644 index 00000000000..34e75401bd2 --- /dev/null +++ b/apps/www/_go/thank-you/example-thank-you.tsx @@ -0,0 +1,36 @@ +import type { GoPageInput } from 'marketing' +import Link from 'next/link' +import { Button } from 'ui' + +const page: GoPageInput = { + template: 'thank-you', + slug: 'example-thank-you', + metadata: { + title: 'Thank You', + description: 'Thanks for signing up.', + }, + hero: { + title: "You're all set!", + description: 'Check your inbox for a confirmation email with your download link.', + }, + sections: [ + { + type: 'single-column', + title: 'While you wait', + description: + 'Explore our documentation and tutorials to get started with Supabase right away.', + children: ( +
+ + +
+ ), + }, + ], +} + +export default page diff --git a/apps/www/app/go/[slug]/page.tsx b/apps/www/app/go/[slug]/page.tsx new file mode 100644 index 00000000000..c26dd252aea --- /dev/null +++ b/apps/www/app/go/[slug]/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' + +import GoPageRenderer from '@/components/Go/GoPageRenderer' +import { SITE_ORIGIN } from '@/lib/constants' +import { getAllGoSlugs, getGoPageBySlug } from '@/lib/go' + +export const revalidate = 30 + +type Params = { slug: string } + +export async function generateStaticParams() { + return getAllGoSlugs().map((slug) => ({ slug })) +} + +export async function generateMetadata({ params }: { params: Promise }): Promise { + const { slug } = await params + const page = getGoPageBySlug(slug) + + if (!page) { + return { title: 'Page Not Found' } + } + + const { metadata } = page + + return { + title: `${metadata.title} | Supabase`, + description: metadata.description, + openGraph: { + title: metadata.title, + description: metadata.description, + url: `${SITE_ORIGIN}/go/${page.slug}`, + images: metadata.ogImage ? [{ url: metadata.ogImage }] : undefined, + }, + twitter: { + card: 'summary_large_image', + title: metadata.title, + description: metadata.description, + images: metadata.ogImage ? [metadata.ogImage] : undefined, + }, + robots: metadata.noIndex ? { index: false, follow: false } : undefined, + } +} + +export default async function GoPage({ params }: { params: Promise }) { + const { slug } = await params + const page = getGoPageBySlug(slug) + + if (!page) { + notFound() + } + + return +} diff --git a/apps/www/components/Go/GoPageRenderer.tsx b/apps/www/components/Go/GoPageRenderer.tsx new file mode 100644 index 00000000000..1fc19c6fb34 --- /dev/null +++ b/apps/www/components/Go/GoPageRenderer.tsx @@ -0,0 +1,45 @@ +import supabaseLogoIcon from 'common/assets/images/supabase-logo-icon.png' +import { GoPageRenderer as MarketingPageRenderer } from 'marketing' +import type { CustomSectionRenderers } from 'marketing' +import Image from 'next/image' +import Link from 'next/link' + +import TweetsSection from './TweetsSection' +import type { GoPage } from '@/types/go' + +const customRenderers: CustomSectionRenderers = { + tweets: TweetsSection, +} + +// eslint-disable-next-line +export default function GoPageRenderer({ page }: { page: GoPage }) { + return ( + <> + + +
+ +
+
+
+ © {new Date().getFullYear()} Supabase Inc. +
+ + Privacy Policy + + + Terms of Service + +
+
+
+ + ) +} diff --git a/apps/www/components/Go/TweetsSection.tsx b/apps/www/components/Go/TweetsSection.tsx new file mode 100644 index 00000000000..1e512fabe70 --- /dev/null +++ b/apps/www/components/Go/TweetsSection.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useBreakpoint } from 'common' +import { range } from 'lib/helpers' +import type { GoTweetsSection } from 'marketing' +import Link from 'next/link' +import { topTweets } from 'shared-data/tweets' +import { Button, cn } from 'ui' +import { TweetCard } from 'ui-patterns/TweetCard' + +function MobileCarousel() { + return ( +
+ + {topTweets.slice(0, 9).map((tweet: any) => ( + + + + ))} +
+ ) +} + +function DesktopMarquee() { + const isMd = useBreakpoint(1024) + const visibleTweets = topTweets.slice(0, isMd ? 12 : 18) + + return ( +
+
+ {range(0, 3).map((_, idx) => ( +
+ {visibleTweets.map((tweet: any, i: number) => ( + 12 && 'hidden lg:block' + )} + > + + + ))} +
+ ))} +
+
+
+ ) +} + +export default function TweetsSection({ section }: { section: GoTweetsSection }) { + return ( +
+ {(section.title || section.description || section.ctas) && ( +
+ {section.title && ( +

{section.title}

+ )} + {section.description && ( +

{section.description}

+ )} + {section.ctas && section.ctas.length > 0 && ( +
+ {section.ctas.map((cta, i) => ( + + ))} +
+ )} +
+ )} +
+ +
+
+ +
+
+ ) +} diff --git a/apps/www/components/Pricing/ComputePricingCalculator.tsx b/apps/www/components/Pricing/ComputePricingCalculator.tsx index d6759d610c5..9239e75c9f8 100644 --- a/apps/www/components/Pricing/ComputePricingCalculator.tsx +++ b/apps/www/components/Pricing/ComputePricingCalculator.tsx @@ -208,7 +208,7 @@ const ComputePricingCalculator = ({ disableInteractivity }: { disableInteractivi {plans.map((plan: any) => ( - setActivePlan(plan)}> + setActivePlan(plan)}> {plan.name} ))} diff --git a/apps/www/lib/go.ts b/apps/www/lib/go.ts new file mode 100644 index 00000000000..3876928737c --- /dev/null +++ b/apps/www/lib/go.ts @@ -0,0 +1,34 @@ +import rawPages from '@/_go' +import { goPageSchema, type GoPage } from '@/types/go' + +export function getAllGoPages(): GoPage[] { + const pages: GoPage[] = [] + const seenSlugs = new Set() + + for (const raw of rawPages) { + const result = goPageSchema.safeParse(raw) + + if (!result.success) { + throw new Error( + `Invalid go page definition (slug: "${(raw as any).slug ?? 'unknown'}"):\n${result.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n')}` + ) + } + + if (seenSlugs.has(result.data.slug)) { + throw new Error(`Duplicate slug "${result.data.slug}" in _go registry`) + } + + seenSlugs.add(result.data.slug) + pages.push(result.data) + } + + return pages +} + +export function getAllGoSlugs(): string[] { + return getAllGoPages().map((p) => p.slug) +} + +export function getGoPageBySlug(slug: string): GoPage | undefined { + return getAllGoPages().find((p) => p.slug === slug) +} diff --git a/apps/www/middleware.ts b/apps/www/middleware.ts new file mode 100644 index 00000000000..72a74634d8a --- /dev/null +++ b/apps/www/middleware.ts @@ -0,0 +1,15 @@ +import { stampFirstReferrerCookie } from 'common/first-referrer-cookie' +import { NextResponse, type NextRequest } from 'next/server' + +export function middleware(request: NextRequest) { + const response = NextResponse.next() + stampFirstReferrerCookie(request, response) + return response +} + +export const config = { + matcher: [ + // Match all paths except Next.js internals and static files + '/((?!api|_next/static|_next/image|favicon.ico|__nextjs).*)', + ], +} diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index c401aafdcc0..1ae45ea0622 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -49,6 +49,7 @@ const nextConfig = { 'shared-data', 'icons', 'api-types', + 'marketing', // needed to make the octokit packages work in /changelog '@octokit/plugin-paginate-graphql', ], diff --git a/apps/www/package.json b/apps/www/package.json index 588d7f4e71d..7affd1ea62b 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -54,6 +54,7 @@ "gsap": "^3.13.0", "icons": "workspace:*", "lucide-react": "*", + "marketing": "workspace:*", "markdown-toc": "^1.2.0", "mdast-util-from-markdown": "^2.0.2", "mdast-util-gfm": "^2.0.2", diff --git a/apps/www/pages/state-of-startups.tsx b/apps/www/pages/state-of-startups.tsx index 2ce5bb779d9..e96aef08b38 100644 --- a/apps/www/pages/state-of-startups.tsx +++ b/apps/www/pages/state-of-startups.tsx @@ -1,22 +1,17 @@ -import { useRouter } from 'next/router' -import { forwardRef, useEffect, useRef, useState } from 'react' - -import { NextSeo } from 'next-seo' -import Link from 'next/link' - -import { motion } from 'framer-motion' -import { Button, cn } from 'ui' - -import { useFlag } from 'common' import DefaultLayout from '~/components/Layouts/Default' +import { StateOfStartupsHeader } from '~/components/SurveyResults/StateOfStartupsHeader' import { SurveyChapter } from '~/components/SurveyResults/SurveyChapter' import { SurveyChapterSection } from '~/components/SurveyResults/SurveyChapterSection' import { SurveySectionBreak } from '~/components/SurveyResults/SurveySectionBreak' -import { StateOfStartupsHeader } from '~/components/SurveyResults/StateOfStartupsHeader' - -import { useSendTelemetryEvent } from '~/lib/telemetry' - import pageData from '~/data/surveys/state-of-startups-2025' +import { useSendTelemetryEvent } from '~/lib/telemetry' +import { useFlag } from 'common' +import { motion } from 'framer-motion' +import { NextSeo } from 'next-seo' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { forwardRef, useEffect, useRef, useState } from 'react' +import { Button, cn } from 'ui' function StateOfStartupsPage() { const router = useRouter() diff --git a/apps/www/tailwind.config.js b/apps/www/tailwind.config.js index e9d3e989b96..0fd6f9784b7 100644 --- a/apps/www/tailwind.config.js +++ b/apps/www/tailwind.config.js @@ -11,6 +11,7 @@ module.exports = config({ './app/**/*.{tsx,ts,js}', './../../packages/ui/src/**/*.{tsx,ts,js}', './../../packages/ui-patterns/src/**/*.{tsx,ts,js}', + './../../packages/marketing/src/**/*.{tsx,ts,js}', ], theme: { extend: { diff --git a/apps/www/types/go.ts b/apps/www/types/go.ts new file mode 100644 index 00000000000..acd1d68ea14 --- /dev/null +++ b/apps/www/types/go.ts @@ -0,0 +1,3 @@ +export type { GoPage, GoPageInput, LeadGenPage, ThankYouPage, LegalPage } from 'marketing' + +export { goPageSchema } from 'marketing' diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts index 6cc06f681ba..46b07c8c937 100644 --- a/e2e/studio/features/table-editor.spec.ts +++ b/e2e/studio/features/table-editor.spec.ts @@ -605,6 +605,33 @@ testRunner('table editor', () => { await deleteTable(page, ref, tableName) }) + test('column actions works as expected', async ({ page, ref }) => { + const tableName = 'pw_table_column_menu' + const colName = 'pw_column' + + // Ensure we're on editor + if (!page.url().includes('/editor')) { + await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) + await waitForTableToLoad(page, ref) + } + + // Create a small table and three rows + await createTable(page, ref, tableName) + await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() + await page.waitForURL(/\/editor\/\d+\?schema=public$/) + + // Copy the column name + await page.getByRole('columnheader', { name: colName }).getByRole('button').nth(1).click() + await page.getByRole('menuitem', { name: 'Copy name' }).click() + + await page.waitForTimeout(500) + const copiedTableResult = await page.evaluate(() => navigator.clipboard.readText()) + expect(copiedTableResult).toBe(colName) + + // Cleanup + await deleteTable(page, ref, tableName) + }) + test('importing, pagination and large data actions works as expected', async ({ page, ref }) => { await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) const tableNameDataActions = 'pw_table_data' diff --git a/examples/auth/expo-social-auth/app/(tabs)/_layout.tsx b/examples/auth/expo-social-auth/app/(tabs)/_layout.tsx index 83f44aa13cc..34b7acd92bd 100644 --- a/examples/auth/expo-social-auth/app/(tabs)/_layout.tsx +++ b/examples/auth/expo-social-auth/app/(tabs)/_layout.tsx @@ -1,11 +1,6 @@ import { Tabs } from 'expo-router'; -import React from 'react'; -import { Platform } from 'react-native'; -import { HapticTab } from '@/components/haptic-tab'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import TabBarBackground from '@/components/ui/tab-bar-background'; -import { Colors } from '@/constants/colors'; +import { Colors } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; export default function TabLayout() { @@ -16,28 +11,11 @@ export default function TabLayout() { screenOptions={{ tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint, headerShown: false, - tabBarButton: HapticTab, - tabBarBackground: TabBarBackground, - tabBarStyle: Platform.select({ - ios: { - // Use a transparent background on iOS to show the blur effect - position: 'absolute', - }, - default: {}, - }), }}> , - }} - /> - , }} /> diff --git a/examples/auth/expo-social-auth/app/(tabs)/explore.tsx b/examples/auth/expo-social-auth/app/(tabs)/explore.tsx deleted file mode 100644 index cc171bb1e59..00000000000 --- a/examples/auth/expo-social-auth/app/(tabs)/explore.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Image } from 'expo-image'; -import { Platform, StyleSheet } from 'react-native'; - -import { Collapsible } from '@/components/collapsible'; -import { ExternalLink } from '@/components/external-link'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; - -export default function TabTwoScreen() { - return ( - - }> - - Explore - - This app includes example code to help you get started. - - - This app has two screens:{' '} - app/(tabs)/index.tsx and{' '} - app/(tabs)/explore.tsx - - - The layout file in app/(tabs)/_layout.tsx{' '} - sets up the tab navigator. - - - Learn more - - - - - You can open this project on Android, iOS, and the web. To open the web version, press{' '} - w in the terminal running this project. - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for - different screen densities - - - - Learn more - - - - - Open app/_layout.tsx to see how to load{' '} - - custom fonts such as this one. - - - - Learn more - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect - what the user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - This template includes an example of an animated component. The{' '} - components/HelloWave.tsx component uses - the powerful react-native-reanimated{' '} - library to create a waving hand animation. - - {Platform.select({ - ios: ( - - The components/ParallaxScrollView.tsx{' '} - component provides a parallax effect for the header image. - - ), - })} - - - ); -} - -const styles = StyleSheet.create({ - headerImage: { - color: '#808080', - bottom: -90, - left: -35, - position: 'absolute', - }, - titleContainer: { - flexDirection: 'row', - gap: 8, - }, -}); diff --git a/examples/auth/expo-social-auth/app/(tabs)/index.tsx b/examples/auth/expo-social-auth/app/(tabs)/index.tsx index 3eda3779baa..e9ad4f762af 100644 --- a/examples/auth/expo-social-auth/app/(tabs)/index.tsx +++ b/examples/auth/expo-social-auth/app/(tabs)/index.tsx @@ -1,16 +1,15 @@ -import { Image } from 'expo-image'; -import { StyleSheet } from 'react-native'; +import { Image } from 'expo-image' +import { StyleSheet } from 'react-native' -import { HelloWave } from '@/components/hello-wave'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import SignOutButton from '@/components/social-auth-buttons/sign-out-button'; -import { useAuthContext } from '@/hooks/use-auth-context'; +import { HelloWave } from '@/components/hello-wave' +import ParallaxScrollView from '@/components/parallax-scroll-view' +import { ThemedText } from '@/components/themed-text' +import { ThemedView } from '@/components/themed-view' +import SignOutButton from '@/components/social-auth-buttons/sign-out-button' +import { useAuthContext } from '@/hooks/use-auth-context' export default function HomeScreen() { - - const { profile } = useAuthContext(); + const { profile } = useAuthContext() return ( - }> + } + > Welcome! @@ -33,7 +33,7 @@ export default function HomeScreen() { - ); + ) } const styles = StyleSheet.create({ @@ -53,4 +53,4 @@ const styles = StyleSheet.create({ left: 0, position: 'absolute', }, -}); +}) diff --git a/examples/auth/expo-social-auth/app/+not-found.tsx b/examples/auth/expo-social-auth/app/+not-found.tsx index 5171f3fd6ca..ca79ddc8ee5 100644 --- a/examples/auth/expo-social-auth/app/+not-found.tsx +++ b/examples/auth/expo-social-auth/app/+not-found.tsx @@ -9,7 +9,7 @@ export default function NotFoundScreen() { <> - This screen does not exist. + This screen doesn't exist. Go to home screen! diff --git a/examples/auth/expo-social-auth/app/_layout.tsx b/examples/auth/expo-social-auth/app/_layout.tsx index c49ad43d58c..b0cf3e6ce2b 100644 --- a/examples/auth/expo-social-auth/app/_layout.tsx +++ b/examples/auth/expo-social-auth/app/_layout.tsx @@ -1,18 +1,17 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'; -import { useFonts } from 'expo-font'; -import { Stack } from 'expo-router'; -import { StatusBar } from 'expo-status-bar'; -import 'react-native-reanimated'; +import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native' +import { Stack } from 'expo-router' +import { StatusBar } from 'expo-status-bar' +import 'react-native-reanimated' -import { SplashScreenController } from '@/components/splash-screen-controller'; +import { SplashScreenController } from '@/components/splash-screen-controller' -import { useAuthContext } from '@/hooks/use-auth-context'; -import { useColorScheme } from '@/hooks/use-color-scheme'; -import AuthProvider from '@/providers/auth-provider'; +import { useAuthContext } from '@/hooks/use-auth-context' +import { useColorScheme } from '@/hooks/use-color-scheme' +import AuthProvider from '@/providers/auth-provider' // Separate RootNavigator so we can access the AuthContext function RootNavigator() { - const { isLoggedIn } = useAuthContext(); + const { isLoggedIn } = useAuthContext() return ( @@ -24,20 +23,11 @@ function RootNavigator() { - ); + ) } export default function RootLayout() { - const colorScheme = useColorScheme(); - - const [loaded] = useFonts({ - SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'), - }); - - if (!loaded) { - // Async font loading only occurs in development. - return null; - } + const colorScheme = useColorScheme() return ( @@ -47,6 +37,5 @@ export default function RootLayout() { - ); + ) } - \ No newline at end of file diff --git a/examples/auth/expo-social-auth/app/login.tsx b/examples/auth/expo-social-auth/app/login.tsx index 7472c985ab5..910dc83bfa8 100644 --- a/examples/auth/expo-social-auth/app/login.tsx +++ b/examples/auth/expo-social-auth/app/login.tsx @@ -1,40 +1,21 @@ -import { Link, Stack } from 'expo-router'; -import { Platform, StyleSheet } from 'react-native'; +import { Link, Stack } from 'expo-router' +import { StyleSheet } from 'react-native' -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; - -import AppleSignInButton from '@/components/social-auth-buttons/apple/apple-sign-in-button'; -import ExpoAppleSignInButton from '@/components/social-auth-buttons/apple/expo-apple-sign-in-button'; -import GoogleSignInButton from '@/components/social-auth-buttons/google/google-sign-in-button'; -import { Image } from 'expo-image'; +import { ThemedText } from '@/components/themed-text' +import { ThemedView } from '@/components/themed-view' export default function LoginScreen() { return ( <> - Login - + Try to navigate to home screen! - - - {Platform.OS === 'ios' && ( - <> - Invertase Apple Sign In - - Expo Apple Sign In - - - )} - {Platform.OS !== 'ios' && ()} - - - ); + ) } const styles = StyleSheet.create({ @@ -43,14 +24,9 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', padding: 20, - gap: 20, }, - socialAuthButtonsContainer: { - display: 'flex', - gap: 10, + link: { + marginTop: 15, + paddingVertical: 15, }, - image: { - width: 100, - height: 100, - }, -}); +}) diff --git a/examples/auth/expo-social-auth/assets/fonts/SpaceMono-Regular.ttf b/examples/auth/expo-social-auth/assets/fonts/SpaceMono-Regular.ttf old mode 100755 new mode 100644 index 28d7ff71776..1cfa3653dc8 Binary files a/examples/auth/expo-social-auth/assets/fonts/SpaceMono-Regular.ttf and b/examples/auth/expo-social-auth/assets/fonts/SpaceMono-Regular.ttf differ diff --git a/examples/auth/expo-social-auth/assets/images/android-icon-background.png b/examples/auth/expo-social-auth/assets/images/android-icon-background.png new file mode 100644 index 00000000000..5ffefc5bb57 Binary files /dev/null and b/examples/auth/expo-social-auth/assets/images/android-icon-background.png differ diff --git a/examples/auth/expo-social-auth/assets/images/android-icon-foreground.png b/examples/auth/expo-social-auth/assets/images/android-icon-foreground.png new file mode 100644 index 00000000000..3a9e5016dca Binary files /dev/null and b/examples/auth/expo-social-auth/assets/images/android-icon-foreground.png differ diff --git a/examples/auth/expo-social-auth/assets/images/android-icon-monochrome.png b/examples/auth/expo-social-auth/assets/images/android-icon-monochrome.png new file mode 100644 index 00000000000..77484ebdbca Binary files /dev/null and b/examples/auth/expo-social-auth/assets/images/android-icon-monochrome.png differ diff --git a/examples/auth/expo-social-auth/assets/images/favicon.png b/examples/auth/expo-social-auth/assets/images/favicon.png index e75f697b180..408bd746615 100644 Binary files a/examples/auth/expo-social-auth/assets/images/favicon.png and b/examples/auth/expo-social-auth/assets/images/favicon.png differ diff --git a/examples/auth/expo-social-auth/assets/images/icon.png b/examples/auth/expo-social-auth/assets/images/icon.png index a0b1526fc7b..7165a53c747 100644 Binary files a/examples/auth/expo-social-auth/assets/images/icon.png and b/examples/auth/expo-social-auth/assets/images/icon.png differ diff --git a/examples/auth/expo-social-auth/assets/supabase-logo-icon.svg b/examples/auth/expo-social-auth/assets/supabase-logo-icon.svg deleted file mode 100644 index ad802ac16ae..00000000000 --- a/examples/auth/expo-social-auth/assets/supabase-logo-icon.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/examples/auth/expo-social-auth/components/collapsible.tsx b/examples/auth/expo-social-auth/components/collapsible.tsx deleted file mode 100644 index e32747ac026..00000000000 --- a/examples/auth/expo-social-auth/components/collapsible.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { PropsWithChildren, useState } from 'react'; -import { StyleSheet, TouchableOpacity } from 'react-native'; - -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/colors'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export function Collapsible({ children, title }: PropsWithChildren & { title: string }) { - const [isOpen, setIsOpen] = useState(false); - const theme = useColorScheme() ?? 'light'; - - return ( - - setIsOpen((value) => !value)} - activeOpacity={0.8}> - - - {title} - - {isOpen && {children}} - - ); -} - -const styles = StyleSheet.create({ - heading: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - content: { - marginTop: 6, - marginLeft: 24, - }, -}); diff --git a/examples/auth/expo-social-auth/components/external-link.tsx b/examples/auth/expo-social-auth/components/external-link.tsx deleted file mode 100644 index dfbd23ea265..00000000000 --- a/examples/auth/expo-social-auth/components/external-link.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Href, Link } from 'expo-router'; -import { openBrowserAsync } from 'expo-web-browser'; -import { type ComponentProps } from 'react'; -import { Platform } from 'react-native'; - -type Props = Omit, 'href'> & { href: Href & string }; - -export function ExternalLink({ href, ...rest }: Props) { - return ( - { - if (Platform.OS !== 'web') { - // Prevent the default behavior of linking to the default browser on native. - event.preventDefault(); - // Open the link in an in-app browser. - await openBrowserAsync(href); - } - }} - /> - ); -} diff --git a/examples/auth/expo-social-auth/components/haptic-tab.tsx b/examples/auth/expo-social-auth/components/haptic-tab.tsx deleted file mode 100644 index 7f3981cb940..00000000000 --- a/examples/auth/expo-social-auth/components/haptic-tab.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs'; -import { PlatformPressable } from '@react-navigation/elements'; -import * as Haptics from 'expo-haptics'; - -export function HapticTab(props: BottomTabBarButtonProps) { - return ( - { - if (process.env.EXPO_OS === 'ios') { - // Add a soft haptic feedback when pressing down on the tabs. - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - props.onPressIn?.(ev); - }} - /> - ); -} diff --git a/examples/auth/expo-social-auth/components/hello-wave.tsx b/examples/auth/expo-social-auth/components/hello-wave.tsx index f9592011c2d..abfc61edf2e 100644 --- a/examples/auth/expo-social-auth/components/hello-wave.tsx +++ b/examples/auth/expo-social-auth/components/hello-wave.tsx @@ -1,11 +1,11 @@ import { useEffect } from 'react'; import { StyleSheet } from 'react-native'; import Animated, { - useAnimatedStyle, useSharedValue, + useAnimatedStyle, + withTiming, withRepeat, withSequence, - withTiming, } from 'react-native-reanimated'; import { ThemedText } from '@/components/themed-text'; @@ -16,7 +16,7 @@ export function HelloWave() { useEffect(() => { rotationAnimation.value = withRepeat( withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })), - 4 // Run the animation 4 times + 4 ); }, [rotationAnimation]); diff --git a/examples/auth/expo-social-auth/components/parallax-scroll-view.tsx b/examples/auth/expo-social-auth/components/parallax-scroll-view.tsx index b9e5a18e76a..93f376484a4 100644 --- a/examples/auth/expo-social-auth/components/parallax-scroll-view.tsx +++ b/examples/auth/expo-social-auth/components/parallax-scroll-view.tsx @@ -8,7 +8,6 @@ import Animated, { } from 'react-native-reanimated'; import { ThemedView } from '@/components/themed-view'; -import { useBottomTabOverflow } from '@/components/ui/tab-bar-background'; import { useColorScheme } from '@/hooks/use-color-scheme'; const HEADER_HEIGHT = 250; @@ -26,7 +25,7 @@ export default function ParallaxScrollView({ const colorScheme = useColorScheme() ?? 'light'; const scrollRef = useAnimatedRef(); const scrollOffset = useScrollViewOffset(scrollRef); - const bottom = useBottomTabOverflow(); + const headerAnimatedStyle = useAnimatedStyle(() => { return { transform: [ @@ -46,11 +45,7 @@ export default function ParallaxScrollView({ return ( - + onAppleButtonPress()} />; -} \ No newline at end of file +} diff --git a/examples/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.ios.tsx b/examples/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.ios.tsx index 4175ac49f07..599cf00a062 100644 --- a/examples/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.ios.tsx +++ b/examples/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.ios.tsx @@ -16,7 +16,7 @@ async function onAppleButtonPress() { // Note: This method must be tested on a real device. On the iOS simulator it always throws an error. const credentialState = await appleAuth.getCredentialStateForUser(appleAuthRequestResponse.user); - console.log('Apple sign in successful:', { credentialState, appleAuthRequestResponse }) + console.log('Apple sign in successful:', { credentialState, appleAuthRequestResponse }); if (credentialState === appleAuth.State.AUTHORIZED && appleAuthRequestResponse.identityToken && appleAuthRequestResponse.authorizationCode) { const signInWithIdTokenCredentials: SignInWithIdTokenCredentials = { @@ -24,28 +24,28 @@ async function onAppleButtonPress() { token: appleAuthRequestResponse.identityToken, nonce: appleAuthRequestResponse.nonce, access_token: appleAuthRequestResponse.authorizationCode, - } + }; - const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials) + const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials); if (error) { - console.error('Error signing in with Apple:', error) + console.error('Error signing in with Apple:', error); } if (data) { - console.log('Apple sign in successful:', data) - router.navigate('/(tabs)/explore') + console.log('Apple sign in successful:', data); + router.navigate('/(tabs)'); } } } export default function AppleSignInButton() { - if (Platform.OS !== 'ios') { return <> } - + if (Platform.OS !== 'ios') { return <>; } + return onAppleButtonPress()} />; -} \ No newline at end of file +} diff --git a/examples/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.tsx b/examples/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.tsx index 66099bef738..599cf00a062 100644 --- a/examples/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.tsx +++ b/examples/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.tsx @@ -1,69 +1,51 @@ import { supabase } from '@/lib/supabase'; +import { AppleButton, appleAuth } from '@invertase/react-native-apple-authentication'; import type { SignInWithIdTokenCredentials } from '@supabase/supabase-js'; -import { useEffect, useState } from 'react'; -import AppleSignin, { type AppleAuthResponse } from 'react-apple-signin-auth'; +import { router } from 'expo-router'; import { Platform } from 'react-native'; -/** - * This is the Apple sign in button for the web. - */ -export default function AppleSignInButton() { - const [nonce, setNonce] = useState(''); - const [sha256Nonce, setSha256Nonce] = useState(''); - - async function onAppleButtonSuccess(appleAuthRequestResponse: AppleAuthResponse) { - console.debug('Apple sign in successful:', { appleAuthRequestResponse }) - if (appleAuthRequestResponse.authorization && appleAuthRequestResponse.authorization.id_token && appleAuthRequestResponse.authorization.code) { - const signInWithIdTokenCredentials: SignInWithIdTokenCredentials = { - provider: 'apple', - token: appleAuthRequestResponse.authorization.id_token, - nonce, - access_token: appleAuthRequestResponse.authorization.code, - } +async function onAppleButtonPress() { + // Performs login request + const appleAuthRequestResponse = await appleAuth.performRequest({ + requestedOperation: appleAuth.Operation.LOGIN, + // Note: it appears putting FULL_NAME first is important, see issue #293 + requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL], + }); - const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials) + // Get the current authentication state for user + // Note: This method must be tested on a real device. On the iOS simulator it always throws an error. + const credentialState = await appleAuth.getCredentialStateForUser(appleAuthRequestResponse.user); - if (error) { - console.error('Error signing in with Apple:', error) - } + console.log('Apple sign in successful:', { credentialState, appleAuthRequestResponse }); - if (data) { - console.log('Apple sign in successful:', data) - } - } - } - - useEffect(() => { - function generateNonce(): string { - const array = new Uint32Array(1); - window.crypto.getRandomValues(array); - return array[0].toString(); + if (credentialState === appleAuth.State.AUTHORIZED && appleAuthRequestResponse.identityToken && appleAuthRequestResponse.authorizationCode) { + const signInWithIdTokenCredentials: SignInWithIdTokenCredentials = { + provider: 'apple', + token: appleAuthRequestResponse.identityToken, + nonce: appleAuthRequestResponse.nonce, + access_token: appleAuthRequestResponse.authorizationCode, }; - async function generateSha256Nonce(nonce: string): Promise { - const buffer = await window.crypto.subtle.digest('sha-256', new TextEncoder().encode(nonce)); - const array = Array.from(new Uint8Array(buffer)); - return array.map(b => b.toString(16).padStart(2, '0')).join(''); - } - - let nonce = generateNonce(); - setNonce(nonce); - - generateSha256Nonce(nonce) - .then((sha256Nonce) => { setSha256Nonce(sha256Nonce) }); - }, []); + const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials); - if (Platform.OS !== 'web') { return <> } - - return ; } + + return onAppleButtonPress()} />; -} \ No newline at end of file +} diff --git a/examples/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.web.tsx b/examples/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.web.tsx new file mode 100644 index 00000000000..fa1befa6121 --- /dev/null +++ b/examples/auth/expo-social-auth/components/social-auth-buttons/apple/apple-sign-in-button.web.tsx @@ -0,0 +1,68 @@ +import { supabase } from '@/lib/supabase'; +import type { SignInWithIdTokenCredentials } from '@supabase/supabase-js'; +import { useEffect, useState } from 'react'; +import AppleSignin, { type AppleAuthResponse } from 'react-apple-signin-auth'; +import { Platform } from 'react-native'; + +export default function AppleSignInButton() { + const [nonce, setNonce] = useState(''); + const [sha256Nonce, setSha256Nonce] = useState(''); + + async function onAppleButtonSuccess(appleAuthRequestResponse: AppleAuthResponse) { + console.debug('Apple sign in successful:', { appleAuthRequestResponse }); + if (appleAuthRequestResponse.authorization && appleAuthRequestResponse.authorization.id_token && appleAuthRequestResponse.authorization.code) { + const signInWithIdTokenCredentials: SignInWithIdTokenCredentials = { + provider: 'apple', + token: appleAuthRequestResponse.authorization.id_token, + nonce, + access_token: appleAuthRequestResponse.authorization.code, + }; + + const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials) + + if (error) { + console.error('Error signing in with Apple:', error); + } + + if (data) { + console.log('Apple sign in successful:', data); + } + }; + } + + useEffect(() => { + function generateNonce(): string { + const array = new Uint32Array(1); + window.crypto.getRandomValues(array); + return array[0].toString(); + }; + + async function generateSha256Nonce(nonce: string): Promise { + const buffer = await window.crypto.subtle.digest('sha-256', new TextEncoder().encode(nonce)); + const array = Array.from(new Uint8Array(buffer)); + return array.map(b => b.toString(16).padStart(2, '0')).join(''); + } + + let nonce = generateNonce(); + setNonce(nonce); + + generateSha256Nonce(nonce) + .then((sha256Nonce) => { setSha256Nonce(sha256Nonce) }); + }, []); + + if (Platform.OS !== 'web') { return <>; } + + return console.error('Apple sign in error:', error)} + />; +} diff --git a/examples/auth/expo-social-auth/components/social-auth-buttons/apple/expo-apple-sign-in-button.tsx b/examples/auth/expo-social-auth/components/social-auth-buttons/apple/expo-apple-sign-in-button.tsx deleted file mode 100644 index 2eb5a8eba9d..00000000000 --- a/examples/auth/expo-social-auth/components/social-auth-buttons/apple/expo-apple-sign-in-button.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { supabase } from '@/lib/supabase'; -import type { SignInWithIdTokenCredentials } from '@supabase/supabase-js'; -import * as AppleAuthentication from 'expo-apple-authentication'; -import { router } from 'expo-router'; -import { Platform, StyleSheet } from 'react-native'; - -async function onAppleButtonPress() { - // Performs login request - try { - const appleAuthRequestResponse = await AppleAuthentication.signInAsync({ - requestedScopes: [ - AppleAuthentication.AppleAuthenticationScope.FULL_NAME, - AppleAuthentication.AppleAuthenticationScope.EMAIL, - ], - }); - - console.log('Apple sign in successful:', { appleAuthRequestResponse }) - - if (appleAuthRequestResponse.identityToken && appleAuthRequestResponse.authorizationCode) { - const signInWithIdTokenCredentials: SignInWithIdTokenCredentials = { - provider: 'apple', - token: appleAuthRequestResponse.identityToken, - access_token: appleAuthRequestResponse.authorizationCode, - } - - const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials) - - if (error) { - console.error('Error signing in with Apple:', error) - } - - if (data) { - console.log('Apple sign in successful:', data) - router.navigate('/(tabs)/explore') - } - } - - } catch (e: any) { - if (e.code === 'ERR_REQUEST_CANCELED') { - console.error('Error signing in with Apple:', e) - } else { - console.error('Error signing in with Apple:', e) - } - } -} - -export default function ExpoAppleSignInButton() { - if (Platform.OS !== 'ios') { return <> } - - return onAppleButtonPress()} - /> -} - - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - button: { - width: 160, height: 45 - }, -}); diff --git a/examples/auth/expo-social-auth/components/social-auth-buttons/google/google-sign-in-button.tsx b/examples/auth/expo-social-auth/components/social-auth-buttons/google/google-sign-in-button.tsx index f502a75e641..6962d83c38e 100644 --- a/examples/auth/expo-social-auth/components/social-auth-buttons/google/google-sign-in-button.tsx +++ b/examples/auth/expo-social-auth/components/social-auth-buttons/google/google-sign-in-button.tsx @@ -15,7 +15,7 @@ export default function GoogleSignInButton() { const parsedUrl = new URL(url); const hash = parsedUrl.hash.substring(1); // Remove the leading '#' const params = new URLSearchParams(hash); - + return { access_token: params.get("access_token"), expires_in: parseInt(params.get("expires_in") || "0"), @@ -25,7 +25,7 @@ export default function GoogleSignInButton() { code: params.get("code"), }; }; - + async function onSignInButtonPress() { console.debug('onSignInButtonPress - start'); const res = await supabase.auth.signInWithOAuth({ @@ -39,7 +39,7 @@ export default function GoogleSignInButton() { const googleOAuthUrl = res.data.url; - if (!googleOAuthUrl) { + if (!googleOAuthUrl) { console.error("no oauth url found!"); return; } @@ -49,7 +49,7 @@ export default function GoogleSignInButton() { `${expo.scheme}://google-auth`, { showInRecents: true }, ).catch((err) => { - console.error('onSignInButtonPress - openAuthSessionAsync - error', { err }) + console.error('onSignInButtonPress - openAuthSessionAsync - error', { err }); console.log(err); }); diff --git a/examples/auth/expo-social-auth/components/social-auth-buttons/google/google-sign-in-button.web.tsx b/examples/auth/expo-social-auth/components/social-auth-buttons/google/google-sign-in-button.web.tsx index 840df98ab8b..36d60d9be2c 100644 --- a/examples/auth/expo-social-auth/components/social-auth-buttons/google/google-sign-in-button.web.tsx +++ b/examples/auth/expo-social-auth/components/social-auth-buttons/google/google-sign-in-button.web.tsx @@ -12,28 +12,28 @@ export default function GoogleSignInButton() { const [sha256Nonce, setSha256Nonce] = useState(''); async function onGoogleButtonSuccess(authRequestResponse: CredentialResponse) { - console.debug('Google sign in successful:', { authRequestResponse }) + console.debug('Google sign in successful:', { authRequestResponse }); if (authRequestResponse.clientId && authRequestResponse.credential) { const signInWithIdTokenCredentials: SignInWithIdTokenCredentials = { provider: 'google', token: authRequestResponse.credential, nonce: nonce, - } + }; - const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials) + const { data, error } = await supabase.auth.signInWithIdToken(signInWithIdTokenCredentials); if (error) { - console.error('Error signing in with Google:', error) + console.error('Error signing in with Google:', error); } if (data) { - console.log('Google sign in successful:', data) + console.log('Google sign in successful:', data); } } } function onGoogleButtonFailure() { - console.error('Error signing in with Google') + console.error('Error signing in with Google'); } useEffect(() => { @@ -41,7 +41,7 @@ export default function GoogleSignInButton() { const array = new Uint32Array(1); window.crypto.getRandomValues(array); return array[0].toString(); - }; + } async function generateSha256Nonce(nonce: string): Promise { const buffer = await window.crypto.subtle.digest('sha-256', new TextEncoder().encode(nonce)); @@ -70,4 +70,4 @@ export default function GoogleSignInButton() { /> ); -} \ No newline at end of file +} diff --git a/examples/auth/expo-social-auth/components/social-auth-buttons/sign-out-button.tsx b/examples/auth/expo-social-auth/components/social-auth-buttons/sign-out-button.tsx index a4215387df9..6416938e1f0 100644 --- a/examples/auth/expo-social-auth/components/social-auth-buttons/sign-out-button.tsx +++ b/examples/auth/expo-social-auth/components/social-auth-buttons/sign-out-button.tsx @@ -1,24 +1,15 @@ -import { useAuthContext } from '@/hooks/use-auth-context'; -import { supabase } from '@/lib/supabase'; -import React from 'react'; -import { Button } from 'react-native'; +import { supabase } from '@/lib/supabase' +import React from 'react' +import { Button } from 'react-native' async function onSignOutButtonPress() { - const { error } = await supabase.auth.signOut() + const { error } = await supabase.auth.signOut() - if (error) { - console.error('Error signing out:', error) - } + if (error) { + console.error('Error signing out:', error) + } } export default function SignOutButton() { - const { isLoggedIn } = useAuthContext(); - - return ( - + + {section.disclaimer && ( +
+

{children}

, a: ({ href, children }) => {children} }} + > + {section.disclaimer} +
+
+ )} + +
+
+ ) +} diff --git a/packages/marketing/src/go/sections/HeroSection.tsx b/packages/marketing/src/go/sections/HeroSection.tsx new file mode 100644 index 00000000000..97abb1c658f --- /dev/null +++ b/packages/marketing/src/go/sections/HeroSection.tsx @@ -0,0 +1,78 @@ +'use client' + +import { Button, cn } from 'ui' + +import type { GoHeroSection } from '../schemas' +import MediaBlock from './MediaBlock' + +export default function HeroSection({ + section, + compact, +}: { + section: GoHeroSection + compact?: boolean +}) { + const hasMedia = !!(section.image || section.video || section.youtubeUrl) + + return ( +
+
+
+ {section.subtitle && ( +

+ {section.subtitle} +

+ )} +

+ {section.title} +

+ {section.description && ( +

+ {section.description} +

+ )} + {section.ctas && section.ctas.length > 0 && ( +
+ {section.ctas.map((cta) => ( + + ))} +
+ )} +
+ + +
+
+ ) +} diff --git a/packages/marketing/src/go/sections/MediaBlock.tsx b/packages/marketing/src/go/sections/MediaBlock.tsx new file mode 100644 index 00000000000..e5632391aae --- /dev/null +++ b/packages/marketing/src/go/sections/MediaBlock.tsx @@ -0,0 +1,70 @@ +import type { GoImage, GoVideo } from '../schemas' + +interface MediaBlockProps { + image?: GoImage + video?: GoVideo + youtubeUrl?: string + className?: string +} + +function getYouTubeEmbedUrl(url: string): string | null { + try { + const parsed = new URL(url) + let id: string | null = null + + if (parsed.hostname === 'youtu.be') { + id = parsed.pathname.slice(1) + } else if (parsed.hostname === 'www.youtube.com' || parsed.hostname === 'youtube.com') { + if (parsed.pathname.startsWith('/embed/')) { + id = parsed.pathname.split('/embed/')[1] + } else { + id = parsed.searchParams.get('v') + } + } + + return id ? `https://www.youtube.com/embed/${id}` : null + } catch { + return null + } +} + +export default function MediaBlock({ image, video, youtubeUrl, className }: MediaBlockProps) { + const embedUrl = youtubeUrl ? getYouTubeEmbedUrl(youtubeUrl) : null + + const media = image ? ( + {image.alt} + ) : video ? ( +