diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 185bb34eabfc7..30163b90e14ff 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -1,5 +1,6 @@ import { PropsWithChildren } from 'react' +import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' import { useFlag } from 'common' import { ClockSkewBanner } from 'components/layouts/AppLayout/ClockSkewBanner' import { IncidentBanner } from 'components/layouts/AppLayout/IncidentBanner' @@ -7,8 +8,12 @@ import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner' import { OrganizationResourceBanner } from '../Organization/HeaderBanner' export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { + const { data: incidents } = useIncidentStatusQuery() + const ongoingIncident = - useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' + useFlag('ongoingIncident') || + process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' || + (incidents?.length ?? 0) > 0 const showNoticeBanner = useFlag('showNoticeBanner') const clockSkewBanner = useFlag('clockSkewBanner') diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 70daf4bde1dd6..e3e491f304f82 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -4,6 +4,10 @@ import { isEmpty, isUndefined, noop } from 'lodash' import { useState } from 'react' import { toast } from 'sonner' +import { useTableApiAccessPrivilegesMutation } from '@/data/privileges/table-api-access-mutation' +import { useDataApiGrantTogglesEnabled } from '@/hooks/misc/useDataApiGrantTogglesEnabled' +import { type ApiPrivilegesByRole } from '@/lib/data-api-types' +import type { DeepReadonly, Prettify } from '@/lib/type-helpers' import { useParams } from 'common' import { type GeneratedPolicy } from 'components/interfaces/Auth/Policies/Policies.utils' import { databasePoliciesKeys } from 'data/database-policies/keys' @@ -33,7 +37,7 @@ import { usePHFlag } from 'hooks/ui/useFlag' import { useUrlState } from 'hooks/ui/useUrlState' import { useTrack } from 'lib/telemetry/track' import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' -import { useTableEditorStateSnapshot } from 'state/table-editor' +import { useTableEditorStateSnapshot, type TableEditorState } from 'state/table-editor' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import type { Dictionary } from 'types' import { SonnerProgress } from 'ui' @@ -57,6 +61,10 @@ import { updateTable, } from './SidePanelEditor.utils' import { SpreadsheetImport } from './SpreadsheetImport/SpreadsheetImport' +import { + useTableApiAccessHandlerWithHistory, + type TableApiAccessParams, +} from './TableEditor/ApiAccessToggle' import { TableEditor } from './TableEditor/TableEditor' import type { ImportContent } from './TableEditor/TableEditor.types' @@ -89,6 +97,12 @@ type SaveTableParamsExisting = SaveTableParamsBase & { } type SaveTablePayloadBase = { + /** + * Comment to set on the table + * + * `null` removes existing comment + * `undefined` leaves comment unchanged + */ comment?: string | null } @@ -106,7 +120,7 @@ type SaveTablePayloadExisting = SaveTablePayloadBase & { rls_enabled?: boolean } -type SaveTableConfiguration = { +type SaveTableConfiguration = Prettify<{ tableId?: number importContent?: ImportContent isRLSEnabled: boolean @@ -114,6 +128,47 @@ type SaveTableConfiguration = { isDuplicateRows: boolean existingForeignKeyRelations: ForeignKeyConstraint[] primaryKey?: Constraint +}> + +const DUMMY_TABLE_API_ACCESS_PARAMS: TableApiAccessParams = { + type: 'new', +} + +const createTableApiAccessHandlerParams = ({ + enabled, + snap, + selectedTable, +}: { + enabled: boolean + snap: DeepReadonly + selectedTable?: PostgresTable +}): TableApiAccessParams | undefined => { + if (!enabled) return undefined + + const tableSidePanel = snap.sidePanel?.type === 'table' ? snap.sidePanel : undefined + if (!tableSidePanel) return undefined + + if (tableSidePanel.mode === 'new') { + return { + type: 'new', + } + } + + if (!selectedTable) return undefined + + if (tableSidePanel.mode === 'duplicate') { + return { + type: 'duplicate', + templateSchemaName: selectedTable.schema, + templateTableName: selectedTable.name, + } + } + + return { + type: 'edit', + schemaName: selectedTable.schema, + tableName: selectedTable.name, + } } export interface SidePanelEditorProps { @@ -142,6 +197,8 @@ export const SidePanelEditor = ({ const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() const getImpersonatedRoleState = useGetImpersonatedRoleState() + + const isApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled() const generatePoliciesFlag = usePHFlag('tableCreateGeneratePolicies') const [isEdited, setIsEdited] = useState(false) @@ -151,6 +208,19 @@ export const SidePanelEditor = ({ connectionString: project?.connectionString, }) + const tableApiAccessParams = createTableApiAccessHandlerParams({ + enabled: isApiGrantTogglesEnabled, + snap, + selectedTable, + }) + const apiAccessToggleHandler = useTableApiAccessHandlerWithHistory( + // Dummy params used to appease TypeScript, actually gated by enabled flag + tableApiAccessParams ?? DUMMY_TABLE_API_ACCESS_PARAMS, + { + enabled: tableApiAccessParams !== undefined, + } + ) + const { confirmOnClose, modalProps: closeConfirmationModalProps } = useConfirmOnClose({ checkIsDirty: () => isEdited, onClose: () => { @@ -179,6 +249,9 @@ export const SidePanelEditor = ({ const { mutateAsync: updatePublication } = useDatabasePublicationUpdateMutation({ onError: () => {}, }) + const { mutateAsync: updateApiPrivileges } = useTableApiAccessPrivilegesMutation({ + onError: () => {}, // Errors handled inline + }) const isDuplicating = snap.sidePanel?.type === 'table' && snap.sidePanel.mode === 'duplicate' @@ -480,6 +553,26 @@ export const SidePanelEditor = ({ } } + const updateTableApiAccess = async ( + table: RetrieveTableResult, + privileges: DeepReadonly + ) => { + if (!project) return console.error('Project is required') + + try { + await updateApiPrivileges({ + projectRef: project.ref, + connectionString: project.connectionString ?? undefined, + relationId: table.id, + privileges, + }) + } catch (error) { + const message = error instanceof Error ? error.message : undefined + const toastDetail = message ? `: ${message}` : '' + toast.error(`Failed to update API access privileges for ${table.name}${toastDetail}`) + } + } + const saveTable = async ({ action, payload, @@ -492,6 +585,19 @@ export const SidePanelEditor = ({ let toastId let saveTableError = false + if (isApiGrantTogglesEnabled && !apiAccessToggleHandler.isSuccess) { + if (apiAccessToggleHandler.isPending) { + toast.info( + 'Cannot save table yet because Data API settings are still loading. Please try again in a moment.' + ) + } else { + toast.error( + 'Cannot save table because there was an error loading Data API settings. Please refresh the page and try again.' + ) + } + return + } + const { importContent, isRLSEnabled, @@ -521,6 +627,15 @@ export const SidePanelEditor = ({ if (isRealtimeEnabled) await updateTableRealtime(table, true) + if (isApiGrantTogglesEnabled) { + const privilegesToSet = apiAccessToggleHandler.data?.schemaExposed + ? apiAccessToggleHandler.data.privileges + : undefined + if (privilegesToSet) { + await updateTableApiAccess(table, privilegesToSet) + } + } + // Invalidate queries for table creation await Promise.all([ queryClient.invalidateQueries({ @@ -577,6 +692,15 @@ export const SidePanelEditor = ({ }) if (isRealtimeEnabled) await updateTableRealtime(table, isRealtimeEnabled) + if (isApiGrantTogglesEnabled) { + const privilegesToSet = apiAccessToggleHandler.data?.schemaExposed + ? apiAccessToggleHandler.data.privileges + : undefined + if (privilegesToSet) { + await updateTableApiAccess(table, privilegesToSet) + } + } + await Promise.all([ queryClient.invalidateQueries({ queryKey: tableKeys.list(project?.ref, table.schema, includeColumns), @@ -614,6 +738,14 @@ export const SidePanelEditor = ({ } if (isTableLike(table)) { await updateTableRealtime(table, isRealtimeEnabled) + if (isApiGrantTogglesEnabled) { + const privilegesToSet = apiAccessToggleHandler.data?.schemaExposed + ? apiAccessToggleHandler.data.privileges + : undefined + if (privilegesToSet) { + await updateTableApiAccess(table, privilegesToSet) + } + } } if (hasError) { @@ -761,6 +893,7 @@ export const SidePanelEditor = ({ closePanel={onClosePanel} saveChanges={saveTable} updateEditorDirty={() => setIsEdited(true)} + apiAccessToggleHandler={apiAccessToggleHandler} /> = { + anon: 'Anonymous (anon)', + authenticated: 'Authenticated', +} + +namespace ApiAccessToggleProps { + type New = { + type: 'new' + } + type Duplicate = { + type: 'duplicate' + templateSchemaName: string + templateTableName: string + } + type Edit = { + type: 'edit' + schemaName: string + tableName: string + } + export type Option = New | Duplicate | Edit +} + +export type TableApiAccessParams = ApiAccessToggleProps.Option + +type FetchState = + | { isError: true; isPending: false; isSuccess: false; data: undefined } + | { + isError: false + isPending: true + isSuccess: false + data: undefined + } + | { + isError: false + isPending: false + isSuccess: true + data: Prettify + } + +type TableApiAccessHandlerResult = + | { schemaExposed: false } + | { + schemaExposed: true + privileges: DeepReadonly + setPrivileges: Dispatch>> + } + +type TableApiAccessHandlerReturn = FetchState + +const useTableApiAccessHandler = ( + params: ApiAccessToggleProps.Option, + { enabled = true } = {} +): TableApiAccessHandlerReturn => { + const isNewTable = params.type === 'new' + const isDuplicate = params.type === 'duplicate' + const isExisting = params.type === 'edit' + + const { selectedSchema } = useQuerySchemaState() + const currentTableSchema = isNewTable + ? selectedSchema + : isDuplicate + ? params.templateSchemaName + : params.schemaName + + const { data: project } = useSelectedProjectQuery({ enabled }) + const schemaExposure = useIsSchemaExposed( + { + projectRef: project?.ref, + schemaName: currentTableSchema, + }, + { enabled } + ) + + const shouldReadExistingGrants = isDuplicate || isExisting + const permissionsTemplateSchema = !shouldReadExistingGrants + ? undefined + : isDuplicate + ? params.templateSchemaName + : params.schemaName + const permissionsTemplateTable = !shouldReadExistingGrants + ? undefined + : isDuplicate + ? params.templateTableName + : params.tableName + const canResolvePrivilegeParams = Boolean( + shouldReadExistingGrants && + project?.ref && + permissionsTemplateSchema && + permissionsTemplateTable + ) + const isPrivilegesQueryEnabled = enabled && canResolvePrivilegeParams + const apiAccessStatus = useTableApiAccessQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString ?? undefined, + schemaName: permissionsTemplateSchema ?? '', + tableNames: permissionsTemplateTable ? [permissionsTemplateTable] : [], + }, + { enabled: isPrivilegesQueryEnabled } + ) + + const privilegesForTable = permissionsTemplateTable + ? apiAccessStatus.data?.[permissionsTemplateTable] + : undefined + + const [privileges, setPrivileges] = useState>( + DEFAULT_DATA_API_PRIVILEGES + ) + + const hasLoadedInitialData = useRef(false) + + const resetState = useStaticEffectEvent(() => { + hasLoadedInitialData.current = !shouldReadExistingGrants + setPrivileges(DEFAULT_DATA_API_PRIVILEGES) + }) + useEffect(() => { + resetState() + }, [params.type, selectedSchema, permissionsTemplateSchema, permissionsTemplateTable, resetState]) + + const syncApiPrivileges = useStaticEffectEvent(() => { + if (hasLoadedInitialData.current) return + if (!apiAccessStatus.isSuccess) return + if (!privilegesForTable) return + + hasLoadedInitialData.current = true + if (privilegesForTable.apiAccessType === 'access') { + setPrivileges(privilegesForTable.privileges) + } else if (privilegesForTable.apiAccessType === 'exposed-schema-no-grants') { + setPrivileges(EMPTY_DATA_API_PRIVILEGES) + } else { + // Used as a dummy default value but is never exposed since the schema is + // not exposed + setPrivileges(DEFAULT_DATA_API_PRIVILEGES) + } + }) + useEffect(() => { + syncApiPrivileges() + }, [apiAccessStatus.status, syncApiPrivileges]) + + const isPending = + !enabled || + schemaExposure.status === 'pending' || + (shouldReadExistingGrants && apiAccessStatus.isPending) + if (isPending) { + return { isError: false, isPending: true, isSuccess: false, data: undefined } + } + + const isError = schemaExposure.isError || (shouldReadExistingGrants && apiAccessStatus.isError) + if (isError) { + return { isError: true, isPending: false, isSuccess: false, data: undefined } + } + + const isSchemaExposed = schemaExposure.data === true + if (!isSchemaExposed) { + return { + isError: false, + isPending: false, + isSuccess: true, + data: { schemaExposed: false }, + } + } + + return { + isError: false, + isPending: false, + isSuccess: true, + data: { + schemaExposed: true, + privileges, + setPrivileges, + }, + } +} + +export type TableApiAccessHandlerWithHistoryReturn = FetchState< + TableApiAccessHandlerResult & { + clearAllPrivileges: () => void + restorePreviousPrivileges: () => void + } +> + +export const useTableApiAccessHandlerWithHistory = ( + params: ApiAccessToggleProps.Option, + { enabled = true } = {} +): TableApiAccessHandlerWithHistoryReturn => { + const innerResult = useTableApiAccessHandler(params, { enabled }) + + const privileges = innerResult.data?.schemaExposed ? innerResult.data.privileges : undefined + const previous = usePreviousDistinct(privileges) + + const clearAllPrivileges = useStaticEffectEvent(() => { + if (!innerResult.isSuccess) return + if (!innerResult.data.schemaExposed) return + innerResult.data?.setPrivileges(EMPTY_DATA_API_PRIVILEGES) + }) + + const restorePreviousPrivileges = useStaticEffectEvent(() => { + if (!innerResult.isSuccess) return + if (!innerResult.data.schemaExposed) return + innerResult.data?.setPrivileges(previous ?? DEFAULT_DATA_API_PRIVILEGES) + }) + + if (!innerResult.isSuccess) { + return innerResult + } + + return { + ...innerResult, + data: { ...innerResult.data, clearAllPrivileges, restorePreviousPrivileges }, + } +} + +type ApiAccessToggleProps = { + projectRef?: string + schemaName?: string + tableName?: string + handler: TableApiAccessHandlerWithHistoryReturn +} + +export const ApiAccessToggle = ({ + projectRef, + schemaName, + tableName, + handler, +}: ApiAccessToggleProps): ReactNode => { + const [isPrivilegesPopoverOpen, setIsPrivilegesPopoverOpen] = useState(false) + + const isPending = handler.isPending + const isError = handler.isError + const isSchemaExposed = handler.data?.schemaExposed + const isDisabled = isPending || isError || !isSchemaExposed + + const privileges = + handler.isSuccess && handler.data.schemaExposed ? handler.data.privileges : undefined + const hasNonEmptyPrivileges = checkDataApiPrivilegesNonEmpty(privileges) + + const handleMasterToggle = (checked: boolean) => { + if (!handler.isSuccess) return + if (!isSchemaExposed) return + + if (checked) { + handler.data?.restorePreviousPrivileges() + } else { + handler.data?.clearAllPrivileges() + } + } + + const handlePrivilegesChange = (role: ApiAccessRole) => (values: string[]) => { + if (!handler.isSuccess) return + if (!isSchemaExposed) return + if (!privileges) return + + handler.data?.setPrivileges((oldPrivileges) => { + return { + ...oldPrivileges, + [role]: values.filter(isApiPrivilegeType), + } + }) + } + + const totalAvailablePrivileges = API_ACCESS_ROLES.length * API_PRIVILEGE_TYPES.length + const totalSelectedPrivileges = Object.values(privileges ?? {}).reduce( + (sum, rolePrivileges) => sum + rolePrivileges.length, + 0 + ) + const hasPartialPrivileges = + totalSelectedPrivileges > 0 && totalSelectedPrivileges < totalAvailablePrivileges + + return ( +
+
+
+
+

+ Data API Access + + This controls which operations the anon and{' '} + authenticated roles can perform on this table via + the Data API. Unselected privileges are revoked from these roles. + +

+

+ Allow this table to be queried via Supabase client libraries or the API directly +

+
+
+ + + + + + {!isDisabled && ( + <> +

Adjust API privileges per role

+
+ {API_ACCESS_ROLES.map((role) => ( +
+

+ {ROLE_LABELS[role]} +

+ + + + + {API_PRIVILEGE_TYPES.map((privilege) => ( + + {privilege} + + ))} + + + +
+ ))} +
+ + )} +
+
+ +
+
+
+ +
+ ) +} + +const SchemaExposureOptions = ({ + projectRef, + schemaName, + tableName, + isPending, + isError, + isSchemaExposed, + hasNonEmptyPrivileges, +}: { + projectRef?: string + schemaName?: string + tableName?: string + isPending: boolean + isError: boolean + isSchemaExposed?: boolean + hasNonEmptyPrivileges?: boolean +}): ReactNode => { + const { selectedDatabaseId } = useDatabaseSelectorStateSnapshot() + + const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) + const { data: loadBalancers } = useLoadBalancersQuery({ projectRef }) + const { data: databases } = useReadReplicasQuery({ projectRef }) + + const apiEndpoint = useMemo(() => { + const isCustomDomainActive = customDomainData?.customDomain?.status === 'active' + if (isCustomDomainActive && selectedDatabaseId === projectRef) { + return `https://${customDomainData.customDomain.hostname}` + } + + const loadBalancerSelected = selectedDatabaseId === 'load-balancer' + if (loadBalancerSelected) { + return loadBalancers?.[0]?.endpoint + } + + const selectedDatabase = databases?.find((db) => db.identifier === selectedDatabaseId) + return selectedDatabase?.restUrl + }, [ + projectRef, + databases, + selectedDatabaseId, + customDomainData?.customDomain?.status, + customDomainData?.customDomain?.hostname, + loadBalancers, + ]) + const apiBaseUrl = useMemo(() => { + if (!apiEndpoint) return undefined + return apiEndpoint.endsWith('/') ? apiEndpoint.slice(0, -1) : apiEndpoint + }, [apiEndpoint]) + + const tablePath = !(schemaName && tableName) + ? undefined + : schemaName === 'public' + ? tableName + : `${schemaName}.${tableName}` + const apiUrl = apiBaseUrl && tablePath ? `${apiBaseUrl}/${tablePath}` : undefined + + return ( + <> + {isError && ( + + )} + + {isSchemaExposed && apiUrl && hasNonEmptyPrivileges && ( + + )} + + {!isPending && !isError && !isSchemaExposed && ( + + To enable API access for this table, you need to first expose the{' '} + {schemaName} schema in your{' '} + + API settings + + . + + } + /> + )} + + ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/RLSManagement.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/RLSManagement.tsx index 278d455c5d338..6bbcfd08456f3 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/RLSManagement.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/RLSManagement/RLSManagement.tsx @@ -25,6 +25,7 @@ interface RLSManagementProps { foreignKeyRelations?: ForeignKey[] // For new tables isNewRecord: boolean isDuplicating: boolean + isExposed?: boolean generatedPolicies?: GeneratedPolicy[] onRLSUpdate?: (isEnabled: boolean) => void onGeneratedPoliciesChange?: (policies: GeneratedPolicy[]) => void @@ -36,6 +37,7 @@ export const RLSManagement = ({ foreignKeyRelations = [], isNewRecord, isDuplicating, + isExposed, generatedPolicies = [], onRLSUpdate, onGeneratedPoliciesChange, @@ -162,7 +164,10 @@ export const RLSManagement = ({ void saveChanges: (params: SaveTableParams) => void updateEditorDirty: () => void + apiAccessToggleHandler: TableApiAccessHandlerWithHistoryReturn } export const TableEditor = ({ @@ -71,6 +75,7 @@ export const TableEditor = ({ closePanel = noop, saveChanges = noop, updateEditorDirty = noop, + apiAccessToggleHandler, }: TableEditorProps) => { const track = useTrack() const snap = useTableEditorStateSnapshot() @@ -78,6 +83,8 @@ export const TableEditor = ({ const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all']) const { docsRowLevelSecurityGuidePath } = useCustomContent(['docs:row_level_security_guide_path']) + const isApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled() + const [params, setParams] = useUrlState() const { data: project } = useSelectedProjectQuery() const { selectedSchema } = useQuerySchemaState() @@ -339,6 +346,12 @@ export const TableEditor = ({ if (!tableFields) return null + const isApiAccessAndPoliciesSectionShown = isApiGrantTogglesEnabled || generatePoliciesEnabled + const isExposed = isApiGrantTogglesEnabled + ? !!apiAccessToggleHandler.data?.schemaExposed && + checkDataApiPrivilegesNonEmpty(apiAccessToggleHandler.data.privileges) + : undefined + return ( )} - {/* [Joshen] Temporarily hide this section if duplicating, as we aren't duplicating policies atm when duplicating tables */} - {/* We should do this thought, but let's do this in another PR as the current one is already quite big */} - {generatePoliciesEnabled && !isDuplicating && ( + {isApiAccessAndPoliciesSectionShown && ( <> - - onUpdateField({ isRLSEnabled: value })} - /> + + {isApiGrantTogglesEnabled && ( + + )} + + {/* [Joshen] Temporarily hide this section if duplicating, as we aren't duplicating policies atm when duplicating tables */} + {/* We should do this thought, but let's do this in another PR as the current one is already quite big */} + {generatePoliciesEnabled && !isDuplicating && ( + onUpdateField({ isRLSEnabled: value })} + /> + )} )} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types.ts index 2f068d8b25998..5db5952c5438f 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types.ts @@ -1,14 +1,15 @@ +import type { Prettify } from '@/lib/type-helpers' import type { Dictionary } from 'types' import type { ColumnField } from '../SidePanelEditor.types' -export interface TableField { +export type TableField = Prettify<{ id: number name: string comment?: string | null columns: ColumnField[] isRLSEnabled: boolean isRealtimeEnabled: boolean -} +}> export interface ImportContent { file?: File diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 5b9dcb157324a..fd41515e8386b 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -472,13 +472,11 @@ const EntityTooltipTrigger = ({ ) } - // Show warning for tables with RLS enabled but no policies - if ( - isDataApiExposedBadgeEnabled && + const isRlsEnabledNoPolicies = entity.type === ENTITY_TYPE.TABLE && - apiAccessData?.hasApiAccess && + apiAccessData?.apiAccessType === 'access' && tableHasRlsEnabledNoPolicyLint - ) { + if (isDataApiExposedBadgeEnabled && isRlsEnabledNoPolicies) { return ( @@ -492,8 +490,9 @@ const EntityTooltipTrigger = ({ ) } - // Show globe icon for tables with API access, RLS enabled and policies - if (isDataApiExposedBadgeEnabled && apiAccessData?.hasApiAccess) { + const isApiExposedWithRlsAndPolicies = + apiAccessData?.apiAccessType === 'access' && !tableHasRlsEnabledNoPolicyLint + if (isDataApiExposedBadgeEnabled && isApiExposedWithRlsAndPolicies) { return ( diff --git a/apps/studio/data/config/project-postgrest-config-query.ts b/apps/studio/data/config/project-postgrest-config-query.ts index a50c479e685ac..4c455992003e0 100644 --- a/apps/studio/data/config/project-postgrest-config-query.ts +++ b/apps/studio/data/config/project-postgrest-config-query.ts @@ -5,6 +5,18 @@ import { get, handleError } from 'data/fetchers' import type { ResponseError, UseCustomQueryOptions } from 'types' import { configKeys } from './keys' +/** + * Parses the exposed schema string returned from PostgREST config. + * + * @param schemaString - e.g., `public,graphql_public` + */ +export const parseDbSchemaString = (schemaString: string): string[] => { + return schemaString + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0) +} + export type ProjectPostgrestConfigVariables = { projectRef?: string } diff --git a/apps/studio/data/privileges/table-api-access-mutation.ts b/apps/studio/data/privileges/table-api-access-mutation.ts new file mode 100644 index 0000000000000..8b49714e4b46e --- /dev/null +++ b/apps/studio/data/privileges/table-api-access-mutation.ts @@ -0,0 +1,114 @@ +import pgMeta from '@supabase/pg-meta' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { + API_ACCESS_ROLES, + API_PRIVILEGE_TYPES, + type ApiPrivilegesByRole, +} from '@/lib/data-api-types' +import type { DeepReadonly } from '@/lib/type-helpers' +import { executeSql } from 'data/sql/execute-sql-query' +import type { UseCustomMutationOptions } from 'types' +import type { ConnectionVars } from '../common.types' +import { lintKeys } from '../lint/keys' +import { invalidateTablePrivilegesQuery } from './table-privileges-query' + +export type TableApiAccessPrivilegesVariables = ConnectionVars & { + relationId: number + privileges: DeepReadonly +} + +export async function updateTableApiAccessPrivileges({ + projectRef, + connectionString, + relationId, + privileges, +}: TableApiAccessPrivilegesVariables) { + const sqlStatements: string[] = [] + + for (const role of API_ACCESS_ROLES) { + const rolePrivileges = privileges[role] + + // Determine which privileges to grant and revoke for this role + const privilegesToGrant = rolePrivileges + const privilegesToRevoke = API_PRIVILEGE_TYPES.filter((p) => !rolePrivileges.includes(p)) + + // Revoke privileges that should be removed + if (privilegesToRevoke.length > 0) { + const revokeGrants = privilegesToRevoke.map((privilegeType) => ({ + grantee: role, + privilegeType, + relationId, + })) + const revokeSql = pgMeta.tablePrivileges.revoke(revokeGrants).sql.trim() + if (revokeSql) sqlStatements.push(revokeSql) + } + + // Grant privileges that should be added + if (privilegesToGrant.length > 0) { + const grantGrants = privilegesToGrant.map((privilegeType) => ({ + grantee: role, + privilegeType, + relationId, + })) + const grantSql = pgMeta.tablePrivileges.grant(grantGrants).sql.trim() + if (grantSql) sqlStatements.push(grantSql) + } + } + + if (sqlStatements.length === 0) { + return null + } + + const { result } = await executeSql<[]>({ + projectRef, + connectionString, + sql: sqlStatements.join('\n'), + queryKey: ['table-api-access', 'update-privileges'], + }) + + return result +} + +type UpdateTableApiAccessPrivilegesData = Awaited> + +export const useTableApiAccessPrivilegesMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions< + UpdateTableApiAccessPrivilegesData, + Error, + TableApiAccessPrivilegesVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => updateTableApiAccessPrivileges(vars), + async onSuccess(data, variables, context) { + const { projectRef } = variables + await Promise.all([ + invalidateTablePrivilegesQuery(queryClient, projectRef), + // This affects the result of the RLS disabled lint, so we need to + // invalidate it + queryClient.invalidateQueries({ + queryKey: lintKeys.lint(projectRef), + }), + ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update API access privileges: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/privileges/table-api-access-query.ts b/apps/studio/data/privileges/table-api-access-query.ts index 5cadf938bf64a..585cebb608b95 100644 --- a/apps/studio/data/privileges/table-api-access-query.ts +++ b/apps/studio/data/privileges/table-api-access-query.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react' import type { ConnectionVars } from 'data/common.types' -import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { useIsSchemaExposed } from 'hooks/misc/useIsSchemaExposed' import { isApiAccessRole, isApiPrivilegeType, type ApiPrivilegesByRole } from 'lib/data-api-types' import type { Prettify } from 'lib/type-helpers' import type { UseCustomQueryOptions } from 'types' @@ -17,18 +17,6 @@ import { const STABLE_EMPTY_ARRAY: any[] = [] const STABLE_EMPTY_OBJECT = {} -/** - * Parses the exposed schema string returned from PostgREST config. - * - * @param schemaString - e.g., `public,graphql_public` - */ -const parseDbSchemaString = (schemaString: string): string[] => { - return schemaString - .split(',') - .map((s) => s.trim()) - .filter((s) => s.length > 0) -} - const getApiPrivilegesByRole = ( privileges: TablePrivilegesData[number]['privileges'] ): ApiPrivilegesByRole => { @@ -72,32 +60,37 @@ export type UseTableApiAccessQueryParams = Prettify< } > +export type DataApiAccessType = 'none' | 'exposed-schema-no-grants' | 'access' + export type TableApiAccessData = | { - hasApiAccess: true + apiAccessType: 'access' privileges: ApiPrivilegesByRole } | { - hasApiAccess: false + apiAccessType: 'none' | 'exposed-schema-no-grants' } -export type TableApiAccessMap = Record +export type TableApiAccessMap = Prettify> export type UseTableApiAccessQueryReturn = | { data: TableApiAccessMap + status: 'success' isSuccess: true isPending: false isError: false } | { data: undefined + status: 'pending' isSuccess: false isPending: true isError: false } | { data: undefined + status: 'error' isSuccess: false isPending: false isError: true @@ -118,52 +111,43 @@ export const useTableApiAccessQuery = ( 'enabled' > = {} ): UseTableApiAccessQueryReturn => { - const { - data: dbSchemaString, - isPending: isConfigPending, - isError: isConfigError, - } = useProjectPostgrestConfigQuery( - { projectRef }, - { enabled, select: ({ db_schema }) => db_schema } - ) - const exposedSchemas = useMemo((): string[] => { - if (!dbSchemaString) return [] - return parseDbSchemaString(dbSchemaString) - }, [dbSchemaString]) - const uniqueTableNames = useMemo(() => { return new Set( tableNames.filter((tableName) => typeof tableName === 'string' && tableName.length > 0) ) }, [tableNames]) const hasTables = uniqueTableNames.size > 0 - const isSchemaExposed = exposedSchemas.includes(schemaName) - const enablePrivilegesQuery = enabled && isSchemaExposed && hasTables - - const { - data: privileges, - isPending: isPrivilegesPending, - isError: isPrivilegesError, - } = useTablePrivilegesQuery( + + const schemaExposureStatus = useIsSchemaExposed({ projectRef, schemaName }, { enabled }) + const isSchemaExposed = schemaExposureStatus.isSuccess && schemaExposureStatus.data === true + + const enablePrivilegesQuery = enabled && hasTables + const privilegeStatus = useTablePrivilegesQuery( { projectRef, connectionString }, { enabled: enablePrivilegesQuery, ...options } ) const result: UseTableApiAccessQueryReturn = useMemo(() => { - const isPending = isConfigPending || (enablePrivilegesQuery && isPrivilegesPending) + const isPending = + !enabled || + schemaExposureStatus.status === 'pending' || + (enablePrivilegesQuery && privilegeStatus.isPending) if (isPending) { return { data: undefined, + status: 'pending', isSuccess: false, isPending: true, isError: false, } } - const isError = isConfigError || (enablePrivilegesQuery && isPrivilegesError) + const isError = + schemaExposureStatus.status === 'error' || (enablePrivilegesQuery && privilegeStatus.isError) if (isError) { return { data: undefined, + status: 'error', isSuccess: false, isPending: false, isError: true, @@ -173,6 +157,7 @@ export const useTableApiAccessQuery = ( if (!hasTables) { return { data: STABLE_EMPTY_OBJECT, + status: 'success', isSuccess: true, isPending: false, isError: false, @@ -181,12 +166,12 @@ export const useTableApiAccessQuery = ( const resultData: TableApiAccessMap = {} const tablePrivilegesByName = isSchemaExposed - ? mapPrivilegesByTableName(privileges, schemaName, uniqueTableNames) + ? mapPrivilegesByTableName(privilegeStatus.data, schemaName, uniqueTableNames) : {} uniqueTableNames.forEach((tableName) => { if (!isSchemaExposed) { - resultData[tableName] = { hasApiAccess: false } + resultData[tableName] = { apiAccessType: 'none' } return } @@ -196,27 +181,28 @@ export const useTableApiAccessQuery = ( resultData[tableName] = hasAnonOrAuthenticatedPrivileges ? { - hasApiAccess: true, + apiAccessType: 'access', privileges: tablePrivileges, } - : { hasApiAccess: false } + : { apiAccessType: 'exposed-schema-no-grants' } }) return { data: resultData, + status: 'success', isSuccess: true, isPending: false, isError: false, } }, [ + enabled, enablePrivilegesQuery, hasTables, - isConfigError, - isConfigPending, - isPrivilegesError, - isPrivilegesPending, + schemaExposureStatus.status, isSchemaExposed, - privileges, + privilegeStatus.isPending, + privilegeStatus.isError, + privilegeStatus.data, schemaName, uniqueTableNames, ]) diff --git a/apps/studio/hooks/misc/useDataApiGrantTogglesEnabled.ts b/apps/studio/hooks/misc/useDataApiGrantTogglesEnabled.ts new file mode 100644 index 0000000000000..b64dff684d866 --- /dev/null +++ b/apps/studio/hooks/misc/useDataApiGrantTogglesEnabled.ts @@ -0,0 +1,14 @@ +import { useFlag } from 'common' +import { usePHFlag } from '../ui/useFlag' + +/** + * Determine whether a user has access to Data API grant toggles. + * + * Requires that the ConfigCat flag for Data API badges and the PostHog flag + * for Table Editor API access are both enabled. + */ +export const useDataApiGrantTogglesEnabled = (): boolean => { + const isDataApiBadgesEnabled = useFlag('dataApiExposedBadge') + const isTableEditorApiAccessEnabled = usePHFlag('tableEditorApiAccessToggle') + return isDataApiBadgesEnabled && !!isTableEditorApiAccessEnabled +} diff --git a/apps/studio/hooks/misc/useIsSchemaExposed.ts b/apps/studio/hooks/misc/useIsSchemaExposed.ts new file mode 100644 index 0000000000000..1d4b6c777faf8 --- /dev/null +++ b/apps/studio/hooks/misc/useIsSchemaExposed.ts @@ -0,0 +1,74 @@ +import { useMemo } from 'react' + +import { + parseDbSchemaString, + useProjectPostgrestConfigQuery, +} from 'data/config/project-postgrest-config-query' + +type UseIsSchemaExposedParams = { + projectRef?: string + schemaName?: string +} + +type UseIsSchemaExposedOptions = { + enabled?: boolean +} + +export type UseIsSchemaExposedReturn = + | { + status: 'pending' + data: undefined + isPending: true + isError: false + isSuccess: false + } + | { + status: 'error' + data: undefined + isPending: false + isError: true + isSuccess: false + } + | { + status: 'success' + data: boolean + isPending: false + isError: false + isSuccess: true + } + +export const useIsSchemaExposed = ( + { projectRef, schemaName }: UseIsSchemaExposedParams, + { enabled = true }: UseIsSchemaExposedOptions = {} +): UseIsSchemaExposedReturn => { + const shouldQueryConfig = enabled && !!projectRef && !!schemaName + const { + data: dbSchemaString, + isPending: isConfigPending, + isError: isConfigError, + } = useProjectPostgrestConfigQuery( + { projectRef }, + { enabled: shouldQueryConfig, select: ({ db_schema }) => db_schema } + ) + + const exposedSchemas = useMemo(() => { + if (!dbSchemaString) return [] + return parseDbSchemaString(dbSchemaString) + }, [dbSchemaString]) + + if (!shouldQueryConfig || isConfigPending) { + return { status: 'pending', data: undefined, isPending: true, isError: false, isSuccess: false } + } + + if (isConfigError) { + return { status: 'error', data: undefined, isPending: false, isError: true, isSuccess: false } + } + + return { + status: 'success', + data: exposedSchemas.includes(schemaName), + isPending: false, + isError: false, + isSuccess: true, + } +} diff --git a/apps/studio/lib/data-api-types.ts b/apps/studio/lib/data-api-types.ts index 5259530b7623d..2b2d082ea41b4 100644 --- a/apps/studio/lib/data-api-types.ts +++ b/apps/studio/lib/data-api-types.ts @@ -1,4 +1,5 @@ import type { TablePrivilegesGrant } from 'data/privileges/table-privileges-grant-mutation' +import type { DeepReadonly } from './type-helpers' export const API_ACCESS_ROLES = ['anon', 'authenticated'] as const export type ApiAccessRole = (typeof API_ACCESS_ROLES)[number] @@ -23,3 +24,20 @@ export const isApiPrivilegeType = (value: string): value is ApiPrivilegeType => } export type ApiPrivilegesByRole = Record + +export const DEFAULT_DATA_API_PRIVILEGES: DeepReadonly = { + anon: [...API_PRIVILEGE_TYPES], + authenticated: [...API_PRIVILEGE_TYPES], +} + +export const EMPTY_DATA_API_PRIVILEGES: DeepReadonly = { + anon: [], + authenticated: [], +} + +export const checkDataApiPrivilegesNonEmpty = ( + privileges: DeepReadonly | undefined +): boolean => { + if (!privileges) return false + return Object.values(privileges).some((privs) => privs.length > 0) +} diff --git a/apps/studio/lib/type-helpers.ts b/apps/studio/lib/type-helpers.ts index 26e2273a496d8..02b486eac2160 100644 --- a/apps/studio/lib/type-helpers.ts +++ b/apps/studio/lib/type-helpers.ts @@ -1,3 +1,9 @@ export type PlainObject = Record export type Prettify = { [K in keyof T]: T[K] } & {} + +export type DeepReadonly = T extends (infer R)[] + ? ReadonlyArray> + : T extends Object + ? { readonly [K in keyof T]: DeepReadonly } + : T diff --git a/apps/www/.env.local.example b/apps/www/.env.local.example index f0c941e429d3c..91311125ac91a 100644 --- a/apps/www/.env.local.example +++ b/apps/www/.env.local.example @@ -13,4 +13,4 @@ NEXT_PUBLIC_URL="http://localhost:3000" NEXT_PUBLIC_HCAPTCHA_SITE_KEY="10000000-ffff-ffff-ffff-000000000001" HCAPTCHA_SECRET_KEY="0x0000000000000000000000000000000000000000" CMS_API_KEY=secret -CMS_PREVIEW_SECRET=secret \ No newline at end of file +CMS_PREVIEW_SECRET=secret diff --git a/apps/www/app/api-v2/contribute/threads/route.ts b/apps/www/app/api-v2/contribute/threads/route.ts new file mode 100644 index 0000000000000..9b7b98457e2e5 --- /dev/null +++ b/apps/www/app/api-v2/contribute/threads/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getUnansweredThreads } from '~/data/contribute' + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams + + // Validate and parse offset - must be a valid non-negative integer + const offsetParam = searchParams.get('offset') + let offset = 0 + if (offsetParam !== null) { + const parsed = parseInt(offsetParam, 10) + if (!isNaN(parsed) && parsed >= 0) { + offset = parsed + } + } + + const channel = searchParams.get('channel') || undefined + const productArea = searchParams.get('product_area') || undefined + const stack = searchParams.get('stack') || undefined + const search = searchParams.get('search') || undefined + + try { + const threads = await getUnansweredThreads(productArea, channel, stack, search, offset, 100) + return NextResponse.json(threads) + } catch (error) { + console.error('Error fetching threads:', error) + return NextResponse.json({ error: 'Failed to fetch threads' }, { status: 500 }) + } +} diff --git a/apps/www/app/contribute/ContributeGuard.tsx b/apps/www/app/contribute/ContributeGuard.tsx new file mode 100644 index 0000000000000..c5ea074d0567b --- /dev/null +++ b/apps/www/app/contribute/ContributeGuard.tsx @@ -0,0 +1,27 @@ +'use client' + +import { useFlag } from 'common' +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' + +/** + * TODO: Deprecate this once the contribute page is launched. + * This component is temporary to block access to the contribute page until the feature is launched. + */ +export function ContributeGuard({ children }: { children: React.ReactNode }) { + //const isPageEnabled = useFlag('contributePage') + const isPageEnabled = true + const router = useRouter() + + useEffect(() => { + if (isPageEnabled !== undefined && !isPageEnabled) { + router.push('/') + } + }, [isPageEnabled, router]) + + // if (isPageEnabled === false) { + // return null + // } + + return <>{children} +} diff --git a/apps/www/app/contribute/about/page.tsx b/apps/www/app/contribute/about/page.tsx new file mode 100644 index 0000000000000..a5592bb76f176 --- /dev/null +++ b/apps/www/app/contribute/about/page.tsx @@ -0,0 +1,298 @@ +import { Realtime } from 'icons' +import { + ArrowLeft, + Award, + Bot, + Code, + DollarSign, + FileText, + Gift, + MessageCircle, + Smartphone, + Sparkles, + Split, + TrendingUp, + Zap, +} from 'lucide-react' +import Image from 'next/image' +import Link from 'next/link' +import { Badge, Button, Separator, cn } from 'ui' +import { GithubAvatar } from '~/components/Contribute/GithubAvatar' +import DefaultLayout from '~/components/Layouts/Default' +import SectionContainer from '~/components/Layouts/SectionContainer' +import { Feature, FeaturesSection as FeaturesSectionType } from '~/data/solutions/solutions.utils' + +const githubUsers = [ + 'aantti', + 'carolmonroe', + 'GaryAustin1', + 'Hallidayo', + 'k1ng-arthur', + 'j4w8n', + 'kallebysantos', + 'saltcod', + 'silentworks', + 'singh-inder', + 'tomaspozo', + 'tristanbob', +] + +const waysToContribute = [ + { + icon: MessageCircle, + heading: 'Help others across the community', + subheading: + 'Help unblock others by answering questions in Discord, GitHub Discussions, Reddit, Twitter, and StackOverflow.', + }, + { + icon: Code, + heading: 'Build and maintain open source projects', + subheading: + 'Contribute to the many open source repositories and community-driven libraries that power Supabase.', + }, + { + icon: FileText, + heading: 'Write docs and guides', + subheading: + 'Help us make Supabase easier to learn and use by improving clarity, adding examples, or filling in gaps.', + }, + { + icon: Sparkles, + heading: 'Do the thing you do better than anyone', + subheading: 'Put your unique skills, interests, or knowledge to work for the community.', + }, +] + +const benefits = [ + { + icon: DollarSign, + heading: 'Paid contributions', + subheading: + 'Top contributors get paid for their efforts. We pay a stipend that recognizes your time and expertise.', + }, + { + icon: Award, + heading: 'Community recognition', + subheading: + 'Get a badge on Discord and flair on Reddit showcasing your SupaSquad status in the community.', + }, + { + icon: Zap, + heading: 'Early access', + subheading: + 'Get first access to new Supabase features and provide feedback directly to our team.', + }, + { + icon: MessageCircle, + heading: 'Direct team access', + subheading: + 'Direct communication channel with Supabase team members for questions, suggestions, and support.', + }, + { + icon: Gift, + heading: 'Exclusive swag', + subheading: + 'Special Supabase merch reserved for SupaSquad members. Show your status with pride.', + }, + { + icon: TrendingUp, + heading: 'Growth opportunities', + subheading: + 'Room to grow from volunteer to paid contributor to paid employee. Your path is up to you.', + }, +] + +const especially = [ + { + id: 'expo', + icon: Smartphone, + heading: ( +
+ Expo Priority +
+ ), + subheading: + 'Help the team by writing docs, creating examples, and making sure our guides are up to date.', + }, + { + id: 'ai', + icon: Bot, + heading: ( +
+ AI / Vectors Priority +
+ ), + subheading: 'Help the team keep our AI / Vector docs and examples up to date. ', + }, + { + id: 'realtime', + icon: Realtime, + heading: 'Realtime', + subheading: + 'Help the team by writing docs, creating examples, and making sure our guides are up to date. Experience with React is an extra bonus.', + }, + { + id: 'branching', + icon: Split, + heading: 'Branching', + subheading: + 'Help the team by writing docs, creating examples, and making sure our guides are up to date.', + }, +] + +// eslint-disable-next-line no-restricted-exports +export default function AboutPage() { + return ( + +
+
+ + + Back to Contribute + + + +

+ Our mission +

+

+ We’re building a community of helpers and contributors who help developers succeed. We + work in the open anywhere our developers are: Discord, GitHub, Reddit, Twitter, Stack + Overflow, and more. +

+
+ + + + + + Ask Supabase + + + + + + +
+
+ {githubUsers.map((username) => ( + + ))} +
+ You? +
+
+
+

Who are we?

+

+ We are a team of developers who are passionate about building the best developer + platform. We help support the community on Discord, GitHub, Reddit, Twitter, and + anywhere else we can find them. +

+
+
+
+ + +

+ Ready to start contributing? +

+ + +
+
+
+
+ ) +} + +const FeaturesSection = ({ + id, + label, + heading, + subheading, + features, + columns = 3, +}: FeaturesSectionType & { columns?: 2 | 3 | 4 }) => { + return ( + +
+ {label && {label}} +

{heading}

+ {subheading && ( +

{subheading}

+ )} +
+
    + {features?.map((feature: Feature, index: number) => ( + + ))} +
+
+ ) +} + +const FeatureItem = ({ feature }: { feature: Feature }) => { + const Icon = feature.icon + + return ( +
  • + {Icon && } +
    + +
    +

    {feature.heading}

    +

    {feature.subheading}

    +
  • + ) +} diff --git a/apps/www/app/contribute/page.tsx b/apps/www/app/contribute/page.tsx new file mode 100644 index 0000000000000..d65ea1f5cd622 --- /dev/null +++ b/apps/www/app/contribute/page.tsx @@ -0,0 +1,40 @@ +import { Hero } from '~/components/Contribute/Hero' +import { UnansweredThreads } from '~/components/Contribute/UnansweredThreads' +import DefaultLayout from '~/components/Layouts/Default' +import { ContributeGuard } from './ContributeGuard' + +// eslint-disable-next-line no-restricted-exports +export default async function ContributePage({ + searchParams, +}: { + searchParams: Promise<{ + product_area?: string | string[] + channel?: string + stack?: string | string[] + search?: string + }> +}) { + const { product_area, channel, stack, search } = await searchParams + + return ( + + +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    + ) +} diff --git a/apps/www/app/contribute/t/[id]/loading.tsx b/apps/www/app/contribute/t/[id]/loading.tsx new file mode 100644 index 0000000000000..09e3c8b320064 --- /dev/null +++ b/apps/www/app/contribute/t/[id]/loading.tsx @@ -0,0 +1,17 @@ +// eslint-disable-next-line no-restricted-exports +export default function Loading() { + return ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ) +} diff --git a/apps/www/app/contribute/t/[id]/page-loading.tsx b/apps/www/app/contribute/t/[id]/page-loading.tsx new file mode 100644 index 0000000000000..ed16c5b15400c --- /dev/null +++ b/apps/www/app/contribute/t/[id]/page-loading.tsx @@ -0,0 +1,27 @@ +// eslint-disable-next-line no-restricted-exports +export default function PageLoading() { + return ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ) +} diff --git a/apps/www/app/contribute/t/[id]/page.tsx b/apps/www/app/contribute/t/[id]/page.tsx new file mode 100644 index 0000000000000..357b49dedbe5f --- /dev/null +++ b/apps/www/app/contribute/t/[id]/page.tsx @@ -0,0 +1,42 @@ +import Link from 'next/link' +import { Suspense } from 'react' +import { ArrowLeft } from 'lucide-react' +import DefaultLayout from '~/components/Layouts/Default' +import { ThreadContent } from '~/components/Contribute/ThreadContent' +import PageLoading from './page-loading' +import type { Metadata } from 'next' +import { ContributeGuard } from '../../ContributeGuard' + +export const metadata: Metadata = { + robots: { + index: false, + follow: true, + }, +} + +// eslint-disable-next-line no-restricted-exports +export default async function ThreadPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + + return ( + + +
    +
    + + + Back to threads + + + }> + + +
    +
    +
    +
    + ) +} diff --git a/apps/www/app/contribute/u/[id]/loading.tsx b/apps/www/app/contribute/u/[id]/loading.tsx new file mode 100644 index 0000000000000..3d95385620de8 --- /dev/null +++ b/apps/www/app/contribute/u/[id]/loading.tsx @@ -0,0 +1,7 @@ +export default function Loading() { + return ( +
    + Loading user profile... +
    + ) +} diff --git a/apps/www/app/contribute/u/[id]/not-found.tsx b/apps/www/app/contribute/u/[id]/not-found.tsx new file mode 100644 index 0000000000000..949d5f01a19cc --- /dev/null +++ b/apps/www/app/contribute/u/[id]/not-found.tsx @@ -0,0 +1,20 @@ +import Link from 'next/link' +import { ArrowLeft } from 'lucide-react' + +export default function NotFound() { + return ( +
    +

    User Not Found

    +

    + This user hasn't created any threads or replies yet, or doesn't exist. +

    + + + Back to threads + +
    + ) +} diff --git a/apps/www/app/contribute/u/[id]/page.tsx b/apps/www/app/contribute/u/[id]/page.tsx new file mode 100644 index 0000000000000..d311e192d0347 --- /dev/null +++ b/apps/www/app/contribute/u/[id]/page.tsx @@ -0,0 +1,50 @@ +import Link from 'next/link' +import { Suspense } from 'react' +import { ArrowLeft } from 'lucide-react' +import DefaultLayout from '~/components/Layouts/Default' +import { UserProfile } from '~/components/Contribute/UserProfile' +import type { Metadata } from 'next' +import { ContributeGuard } from '../../ContributeGuard' + +export const metadata: Metadata = { + robots: { + index: false, + follow: true, + }, +} + +function UserProfileLoading() { + return ( +
    + Loading user profile... +
    + ) +} + +// eslint-disable-next-line no-restricted-exports +export default async function UserProfilePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const username = decodeURIComponent(id) + + return ( + + +
    +
    + + + Back to threads + + + }> + + +
    +
    +
    +
    + ) +} diff --git a/apps/www/components/Contribute/ContributorBenefits.tsx b/apps/www/components/Contribute/ContributorBenefits.tsx new file mode 100644 index 0000000000000..853a6387fae30 --- /dev/null +++ b/apps/www/components/Contribute/ContributorBenefits.tsx @@ -0,0 +1,74 @@ +import { Award, Zap, MessageCircle, DollarSign, Gift, TrendingUp, LucideIcon } from 'lucide-react' + +interface Benefit { + icon: LucideIcon + title: string + description: string +} + +const benefits: Benefit[] = [ + { + icon: DollarSign, + title: 'Paid Contributions', + description: + 'Top contributors get paid for their efforts. We pay a stipend that recognizes your time and expertise.', + }, + { + icon: Award, + title: 'Community Recognition', + description: + 'Get a Badge on Discord and flair on Reddit showcasing your SupaSquad status in the community.', + }, + { + icon: Zap, + title: 'Early Access', + description: + 'Get first access to new Supabase features and provide feedback directly to our team.', + }, + { + icon: MessageCircle, + title: 'Direct Team Access', + description: + 'Direct communication channel with Supabase team members for questions, suggestions and support.', + }, + + { + icon: Gift, + title: 'Exclusive SWAG', + description: + 'Special Supabase merch reserved for SupaSquad members. Show your status with pride.', + }, + { + icon: TrendingUp, + title: 'Growth Opportunities', + description: + 'Room to grow from volunteer to paid contributor to paid employee. Your path is up to you.', + }, +] + +export function ContributorBenefits() { + return ( +
    +
    +

    Benefits for contributors

    +

    + Becoming a contributor comes with real benefits. From community recognition to paid + opportunities, we value your time and impact. +

    +
    + +
    + {benefits.map((benefit) => { + const Icon = benefit.icon + return ( +
    + +

    {benefit.title}

    +

    {benefit.description}

    +
    + ) + })} +
    +
    + ) +} diff --git a/apps/www/components/Contribute/Conversation.tsx b/apps/www/components/Contribute/Conversation.tsx new file mode 100644 index 0000000000000..80f55325a1ee1 --- /dev/null +++ b/apps/www/components/Contribute/Conversation.tsx @@ -0,0 +1,117 @@ +import Link from 'next/link' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { Badge, Card, CardContent } from 'ui' +import { getThreadRepliesById } from '~/data/contribute' +import type { ThreadRow } from '~/types/contribute' +import { HelpOnPlatformButton } from './HelpOnPlatformButton' +import { DiscordIcon, GitHubIcon, RedditIcon } from './Icons' +import { markdownComponents } from './markdownComponents' +import { RepliesList } from './RepliesList' + +export async function Conversation({ thread }: { thread: ThreadRow }) { + const { question, replies } = await getThreadRepliesById(thread.thread_key) + + if (!question && replies.length === 0) { + return null + } + + const validReplies = replies.filter((reply: { content: string | null }) => reply.content) + + return ( +
    + {/* Title, Question, and First Reply Section */} + {question && question.content && ( +
    + {/* Title */} +
    +
    +
    + {thread.channel === 'discord' && } + {thread.channel === 'reddit' && } + {thread.channel === 'github' && } + {thread.channel} + · + {thread.posted} +
    + +
    +

    {thread.title}

    +

    + by{' '} + + {thread.user} + +

    +
    + + {/* Question */} +
    +
    + + {question.content} + +
    +
    + {question.author && ( + <> + OP + + {question.author} + + + )} + {question.author && question.ts && ·} + {question.ts && question.external_activity_url ? ( + + {new Date(question.ts).toLocaleString()} + + ) : ( + question.ts && {new Date(question.ts).toLocaleString()} + )} +
    +
    +
    + )} + + {/* Summary Section */} + {thread.summary && ( +
    +

    How to help

    + + +
    + + {thread.summary} + +
    +
    +
    +
    + )} + + {/* Remaining Replies Section */} + {validReplies.length > 0 && ( + + )} +
    + ) +} diff --git a/apps/www/components/Contribute/FilterPopover.tsx b/apps/www/components/Contribute/FilterPopover.tsx new file mode 100644 index 0000000000000..557b9deb3f887 --- /dev/null +++ b/apps/www/components/Contribute/FilterPopover.tsx @@ -0,0 +1,147 @@ +'use client' + +import { X } from 'lucide-react' +import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs' +import { useState } from 'react' +import { + Button, + cn, + Popover_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + ScrollArea, +} from 'ui' + +interface FilterPopoverProps { + allProductAreas: string[] + allStacks: string[] + trigger: React.ReactNode +} + +export function FilterPopover({ allProductAreas, allStacks, trigger }: FilterPopoverProps) { + const [open, setOpen] = useState(false) + const [productAreas, setProductAreas] = useQueryState( + 'product_area', + parseAsArrayOf(parseAsString).withOptions({ + shallow: false, + }) + ) + const [stacks, setStacks] = useQueryState( + 'stack', + parseAsArrayOf(parseAsString).withOptions({ + shallow: false, + }) + ) + + function handleProductAreaClick(area: string) { + const current = productAreas || [] + if (current.includes(area)) { + setProductAreas(current.filter((a) => a !== area)) + } else { + setProductAreas([...current, area]) + } + } + + function handleStackClick(tech: string) { + const current = stacks || [] + if (current.includes(tech)) { + setStacks(current.filter((t) => t !== tech)) + } else { + setStacks([...current, tech]) + } + } + + function handleClearAll() { + setProductAreas(null) + setStacks(null) + } + + return ( + + {trigger} + +
    + {/* Header */} +
    +

    + Filter threads +

    + +
    + + +
    + {/* Product Area Section */} +
    +

    Product area

    +
    + {allProductAreas + .filter((area) => area !== 'Other') + .map((area) => { + const isSelected = productAreas?.includes(area) ?? false + return ( + + ) + })} +
    +
    + + {/* Tech Stack Section */} +
    +

    Tech stack

    +
    + {allStacks + .filter((tech) => tech !== 'Other') + .map((tech) => { + const isSelected = stacks?.includes(tech) ?? false + return ( + + ) + })} +
    +
    +
    +
    +
    +
    +
    + ) +} diff --git a/apps/www/components/Contribute/GithubAvatar.tsx b/apps/www/components/Contribute/GithubAvatar.tsx new file mode 100644 index 0000000000000..799aec460a480 --- /dev/null +++ b/apps/www/components/Contribute/GithubAvatar.tsx @@ -0,0 +1,55 @@ +'use client' + +import Image from 'next/image' +import Link from 'next/link' +import { useState } from 'react' + +interface GithubAvatarProps { + username: string + size?: number + className?: string +} + +// Basic username validation - GitHub usernames must be alphanumeric with hyphens/underscores +function isValidGithubUsername(username: string): boolean { + if (!username || username.trim().length === 0) return false + // GitHub username rules: alphanumeric, hyphens, underscores, no consecutive hyphens/underscores, max 39 chars + const githubUsernameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9]|-(?!-)|_(?!_))*[a-zA-Z0-9]?$/ + return username.length <= 39 && githubUsernameRegex.test(username) +} + +export function GithubAvatar({ username, size = 80, className }: GithubAvatarProps) { + const [imageError, setImageError] = useState(false) + const [imageSrc, setImageSrc] = useState(`https://github.com/${username}.png`) + + // Validate username and use fallback if invalid + const isValid = isValidGithubUsername(username) + const fallbackSrc = isValid + ? `https://github.com/identicons/${username}.png` + : '/images/blog/blog-placeholder.png' + + const handleImageError = () => { + if (!imageError) { + setImageError(true) + setImageSrc(fallbackSrc) + } + } + + return ( + + {`${username}'s + + ) +} diff --git a/apps/www/components/Contribute/HelpOnPlatformButton.tsx b/apps/www/components/Contribute/HelpOnPlatformButton.tsx new file mode 100644 index 0000000000000..30c85e51d7579 --- /dev/null +++ b/apps/www/components/Contribute/HelpOnPlatformButton.tsx @@ -0,0 +1,25 @@ +import { Button } from 'ui' +import type { ThreadSource } from '~/types/contribute' + +interface HelpOnPlatformButtonProps { + channel: ThreadSource + externalActivityUrl: string + className?: string +} + +export function HelpOnPlatformButton({ + channel, + externalActivityUrl, + className = 'w-full sm:w-fit', +}: HelpOnPlatformButtonProps) { + const platformName = + channel === 'discord' ? 'Discord' : channel === 'reddit' ? 'Reddit' : 'GitHub' + + return ( + + ) +} diff --git a/apps/www/components/Contribute/Hero.tsx b/apps/www/components/Contribute/Hero.tsx new file mode 100644 index 0000000000000..1f84b3f5957bd --- /dev/null +++ b/apps/www/components/Contribute/Hero.tsx @@ -0,0 +1,24 @@ +import Link from 'next/link' +import { Button } from 'ui' + +export function Hero() { + return ( +
    +

    + Supabase Contribute +

    +

    + Join the Supabase
    + Contributor Community +

    +

    + Jump in to these unresolved threads and share your knowledge with fellow builders. Every + question answered helps someone build something amazing. +

    + + +
    + ) +} diff --git a/apps/www/components/Contribute/Icons.tsx b/apps/www/components/Contribute/Icons.tsx new file mode 100644 index 0000000000000..0e933460ec833 --- /dev/null +++ b/apps/www/components/Contribute/Icons.tsx @@ -0,0 +1,67 @@ +import type { SVGProps } from 'react' +import { cn } from 'ui' + +export function RedditIcon(props: SVGProps) { + return ( + + Reddit + + + ) +} + +export function DiscordIcon(props: SVGProps) { + return ( + + Discord + + + ) +} + +export function GitHubIcon(props: SVGProps) { + return ( + + GitHub + + + ) +} + +export function AllIcon(props: SVGProps) { + return ( + + + + + + + + + ) +} diff --git a/apps/www/components/Contribute/Leaderboard.tsx b/apps/www/components/Contribute/Leaderboard.tsx new file mode 100644 index 0000000000000..c912b867943d4 --- /dev/null +++ b/apps/www/components/Contribute/Leaderboard.tsx @@ -0,0 +1,89 @@ +'use client' + +import Link from 'next/link' +import { ArrowLeft } from 'lucide-react' +import { parseAsString, useQueryState } from 'nuqs' +import { + Select_Shadcn_ as Select, + SelectContent_Shadcn_ as SelectContent, + SelectItem_Shadcn_ as SelectItem, + SelectTrigger_Shadcn_ as SelectTrigger, + SelectValue_Shadcn_ as SelectValue, +} from 'ui' +import { LeaderboardContent } from './LeaderboardContent' +import { LEADERBOARD_PERIODS } from '~/data/contribute' +import { use } from 'react' + +export function LeaderboardClient({ + initialLeaderboardPromise, + initialPeriod, +}: { + initialLeaderboardPromise: Promise + initialPeriod: (typeof LEADERBOARD_PERIODS)[number] +}) { + const [period, setPeriod] = useQueryState( + 'period', + parseAsString.withDefault('all').withOptions({ + shallow: false, + }) + ) + + const validPeriod = LEADERBOARD_PERIODS.includes(period as (typeof LEADERBOARD_PERIODS)[number]) + ? (period as (typeof LEADERBOARD_PERIODS)[number]) + : 'all' + + const periodLabels: Record<(typeof LEADERBOARD_PERIODS)[number], string> = { + all: 'All time', + year: 'This year', + quarter: 'This quarter', + month: 'This month', + week: 'This week', + today: 'Today', + } + + // Use the initial promise if period hasn't changed, otherwise it will refetch via navigation + const leaderboard = use(initialLeaderboardPromise) + + return ( +
    + {/* Back Link */} + + + Back to Contribute + + + {/* Header */} +
    +

    Top Community Contributors

    + + How does this work? + +
    + + {/* Period Selector */} +
    + +
    + + {/* Leaderboard Content */} + +
    + ) +} diff --git a/apps/www/components/Contribute/LeaderboardContent.tsx b/apps/www/components/Contribute/LeaderboardContent.tsx new file mode 100644 index 0000000000000..36da66cb379e0 --- /dev/null +++ b/apps/www/components/Contribute/LeaderboardContent.tsx @@ -0,0 +1,170 @@ +'use client' + +import { cn } from 'ui' +import type { LeaderboardRow } from '~/types/contribute' + +function getInitials(name: string | null): string { + if (!name) return '?' + const parts = name.trim().split(/\s+/) + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() + } + return name.substring(0, 2).toUpperCase() +} + +function getAvatarColor(name: string | null): string { + if (!name) return 'bg-surface-300' + const colors = [ + 'bg-blue-500', + 'bg-purple-500', + 'bg-pink-500', + 'bg-red-500', + 'bg-orange-500', + 'bg-yellow-500', + 'bg-green-500', + 'bg-teal-500', + 'bg-cyan-500', + 'bg-indigo-500', + ] + let hash = 0 + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash) + } + return colors[Math.abs(hash) % colors.length] +} + +function Avatar({ name, size = 'md' }: { name: string | null; size?: 'sm' | 'md' | 'lg' }) { + const initials = getInitials(name) + const sizeClasses = { + sm: 'h-8 w-8 text-xs', + md: 'h-12 w-12 text-base', + lg: 'h-20 w-20 text-2xl', + } + + return ( +
    + {initials} +
    + ) +} + +function TopThreeCard({ + rank, + name, + score, + isFirst, +}: { + rank: number + name: string | null + score: number + isFirst: boolean +}) { + return ( +
    +
    + +
    +
    +

    {name || 'Anonymous'}

    +
    {score.toLocaleString()}
    +
    Replies
    +
    +
    + ) +} + +function LeaderboardRowItem({ + rank, + name, + score, +}: { + rank: number + name: string | null + score: number +}) { + return ( +
    +
    {rank}
    + +
    +
    {name || 'Anonymous'}
    +
    +
    +
    {score.toLocaleString()}
    +
    Replies
    +
    +
    + ) +} + +export function LeaderboardContent({ leaderboard }: { leaderboard: LeaderboardRow[] }) { + if (!leaderboard || leaderboard.length === 0) { + return ( +
    + No leaderboard data available +
    + ) + } + + const topThree = leaderboard.slice(0, 3) + const rest = leaderboard.slice(3) + + return ( +
    + {/* Top 3 Cards */} + {topThree.length > 0 && ( +
    + {topThree.length >= 2 && ( + + )} + {topThree.length >= 1 && ( + + )} + {topThree.length >= 3 && ( + + )} +
    + )} + + {/* Rest of the leaderboard */} + {rest.length > 0 && ( +
    + {rest.map((entry, index) => ( + + ))} +
    + )} +
    + ) +} diff --git a/apps/www/components/Contribute/PlatformTabs.tsx b/apps/www/components/Contribute/PlatformTabs.tsx new file mode 100644 index 0000000000000..29435ce83b917 --- /dev/null +++ b/apps/www/components/Contribute/PlatformTabs.tsx @@ -0,0 +1,34 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' + +const tabs = [ + { href: '/contribute/reddit', label: 'Reddit' }, + { href: '/contribute/discord', label: 'Discord' }, + { href: '/contribute/github', label: 'GitHub' }, +] + +export function PlatformTabs() { + const pathname = usePathname() + + return ( + + ) +} diff --git a/apps/www/components/Contribute/RepliesList.tsx b/apps/www/components/Contribute/RepliesList.tsx new file mode 100644 index 0000000000000..c10bbf36b79a2 --- /dev/null +++ b/apps/www/components/Contribute/RepliesList.tsx @@ -0,0 +1,102 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { Badge, Button, Card, CardContent, CardFooter } from 'ui' +import { markdownComponents } from './markdownComponents' + +interface Reply { + id: string + content: string | null + author: string | null + ts: string | null + external_activity_url: string | null +} + +interface RepliesListProps { + replies: Reply[] + questionAuthor: string | null + totalReplyCount?: number +} + +export function RepliesList({ replies, questionAuthor, totalReplyCount }: RepliesListProps) { + const [isExpanded, setIsExpanded] = useState(false) + const hasMoreThanThree = replies.length > 3 + const displayedReplies = isExpanded ? replies : replies.slice(0, 3) + + if (replies.length === 0) { + return null + } + + const displayCount = totalReplyCount || replies.length + + return ( +
    +

    + {displayCount} {displayCount === 1 ? 'reply' : 'replies'} +

    + + + {displayedReplies.map((reply, index) => { + const timestamp = reply.ts ? new Date(reply.ts).toLocaleString() : null + const isOP = reply.author === questionAuthor + + return ( +
    +
    +
    + + {reply.content || ''} + +
    +
    + {reply.author && ( + <> + {isOP && OP} + + {reply.author} + + + )} + {reply.author && timestamp && ·} + {timestamp && reply.external_activity_url ? ( + + {timestamp} + + ) : ( + timestamp && {timestamp} + )} +
    +
    +
    + ) + })} +
    + {hasMoreThanThree && ( + + + + )} +
    +
    + ) +} diff --git a/apps/www/components/Contribute/ThreadContent.tsx b/apps/www/components/Contribute/ThreadContent.tsx new file mode 100644 index 0000000000000..5222f3f42af17 --- /dev/null +++ b/apps/www/components/Contribute/ThreadContent.tsx @@ -0,0 +1,66 @@ +import { notFound } from 'next/navigation' +import { Suspense } from 'react' +import { Badge } from 'ui' +import { Conversation } from '~/components/Contribute/Conversation' +import { HelpOnPlatformButton } from '~/components/Contribute/HelpOnPlatformButton' +import { getThreadById } from '~/data/contribute' +import Loading from '../../app/contribute/t/[id]/loading' + +export async function ThreadContent({ id }: { id: string }) { + const thread = await getThreadById(id) + + if (!thread) { + notFound() + } + + return ( +
    + {/* Conversation Section (includes title, question, and first reply) */} + }> + + + + {/* Metadata and Actions Section */} +
    +
    + {thread.product_areas.filter((area: string) => area !== 'Other').length > 0 && ( +
    +

    Product areas

    +
    + {thread.product_areas + .filter((area: string) => area !== 'Other') + .map((area: string) => ( + + {area} + + ))} +
    +
    + )} + + {thread.stack.filter((tech: string) => tech !== 'Other').length > 0 && ( +
    +

    Stack

    +
    + {thread.stack + .filter((tech: string) => tech !== 'Other') + .map((tech: string) => ( + + {tech} + + ))} +
    +
    + )} +
    + {/* CTA Button */} +
    + +
    +
    +
    + ) +} diff --git a/apps/www/components/Contribute/UnansweredThreads.tsx b/apps/www/components/Contribute/UnansweredThreads.tsx new file mode 100644 index 0000000000000..dcb2300d49ed3 --- /dev/null +++ b/apps/www/components/Contribute/UnansweredThreads.tsx @@ -0,0 +1,48 @@ +import { Admonition } from 'ui-patterns' +import { + getAllProductAreas, + getAllStacks, + getChannelCounts, + getUnansweredThreads, +} from '~/data/contribute' +import { UnansweredThreadsTable } from './UnansweredThreadsTable' + +export async function UnansweredThreads({ + product_area, + channel, + stack, + search, +}: { + product_area?: string | string[] + channel?: string + stack?: string | string[] + search?: string +}) { + try { + const [threads, channelCounts, allProductAreas, allStacks] = await Promise.all([ + getUnansweredThreads(product_area, channel, stack, search), + getChannelCounts(product_area, stack, search), + getAllProductAreas(), + getAllStacks(), + ]) + + return ( + + ) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + return ( + + ) + } +} diff --git a/apps/www/components/Contribute/UnansweredThreadsTable.tsx b/apps/www/components/Contribute/UnansweredThreadsTable.tsx new file mode 100644 index 0000000000000..03bb37a789bb8 --- /dev/null +++ b/apps/www/components/Contribute/UnansweredThreadsTable.tsx @@ -0,0 +1,657 @@ +'use client' + +import { Filter, MessageSquareReply, Search, X } from 'lucide-react' +import Link from 'next/link' +import { parseAsString, useQueryState } from 'nuqs' +import type { ReactNode } from 'react' +import { useEffect, useMemo, useState, useTransition } from 'react' +import { + Badge, + Button, + Card, + cn, + Input_Shadcn_, + Popover_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' + +import type { ThreadRow } from '~/types/contribute' +import { FilterPopover } from './FilterPopover' +import { DiscordIcon, GitHubIcon, RedditIcon } from './Icons' + +interface TabConfig { + id: string + label: string + icon?: React.ComponentType<{ className?: string }> + iconColor?: string +} + +function CountSkeleton() { + return +} + +function ThreadsTable({ + threads, + productArea, + search, +}: { + threads: ThreadRow[] + productArea: string | null + search: string | null +}) { + return ( + + + + + Thread + Stack + Replies + + + + {threads.length === 0 ? ( + + + No threads found + + + ) : ( + threads.map((thread) => ( + + )) + )} + + + Showing {threads.length} {threads.length === 1 ? 'thread' : 'threads'} + +
    +
    + ) +} + +export function UnansweredThreadsTable({ + threads: initialThreads, + channelCounts, + allProductAreas, + allStacks, +}: { + threads: ThreadRow[] + channelCounts: { all: number; discord: number; reddit: number; github: number } + allProductAreas: string[] + allStacks: string[] +}) { + const [threads, setThreads] = useState(initialThreads) + const [isLoadingMore, setIsLoadingMore] = useState(false) + const [hasMore, setHasMore] = useState(initialThreads.length === 100) + + const [searchInput, setSearchInput] = useState('') + const [search, setSearch] = useQueryState( + 'search', + parseAsString.withOptions({ + shallow: false, // notify server, re-render RSC tree + }) + ) + + const [_, startTransition] = useTransition() + const [channel, setChannel] = useQueryState( + 'channel', + parseAsString.withDefault('all').withOptions({ + shallow: false, // notify server, re-render RSC tree + }) + ) + const [productArea] = useQueryState('product_area', parseAsString) + const [stack] = useQueryState('stack', parseAsString) + + const tabs: TabConfig[] = [ + { + id: 'all', + label: 'All', + }, + { + id: 'discord', + label: 'Discord', + icon: DiscordIcon, + iconColor: 'text-[#5865F2]', + }, + { + id: 'reddit', + label: 'Reddit', + icon: RedditIcon, + iconColor: 'text-[#FF4500]', + }, + { + id: 'github', + label: 'GitHub', + icon: GitHubIcon, + iconColor: 'text-foreground', + }, + ] + + const validTabs = ['all', 'discord', 'reddit', 'github'] as const + const currentTab = ( + validTabs.includes(channel as (typeof validTabs)[number]) ? channel : 'all' + ) as (typeof validTabs)[number] + + // Reset threads when filters change + useEffect(() => { + setThreads(initialThreads) + setHasMore(initialThreads.length === 100) + }, [initialThreads]) + + async function handleTabChange(value: string) { + startTransition(async () => { + await setChannel(value) + }) + } + + async function handleLoadMore() { + setIsLoadingMore(true) + try { + const params = new URLSearchParams() + params.set('offset', threads.length.toString()) + if (channel && channel !== 'all') params.set('channel', channel) + if (productArea) params.set('product_area', productArea) + if (stack) params.set('stack', stack) + if (search) params.set('search', search) + + const response = await fetch(`/api-v2/contribute/threads?${params.toString()}`) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ + error: `Failed to load threads: ${response.status} ${response.statusText}`, + })) + throw new Error(errorData.error || `HTTP error! status: ${response.status}`) + } + + const newThreads = await response.json() + + if (!Array.isArray(newThreads)) { + throw new Error('Invalid response format: expected an array of threads') + } + + if (newThreads.length < 100) { + setHasMore(false) + } + + setThreads((prev) => [...prev, ...newThreads]) + } catch (error) { + console.error('Error loading more threads:', error) + // Optionally show user-facing error message + // You could add a toast notification or error state here + } finally { + setIsLoadingMore(false) + } + } + + function handleSearchSubmit(e: React.FormEvent) { + e.preventDefault() + if (searchInput.trim()) { + setSearch(searchInput.trim()) + } else { + setSearch(null) + } + } + + function handleSearchKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Enter') { + e.preventDefault() + if (searchInput.trim()) { + setSearch(searchInput.trim()) + } else { + setSearch(null) + } + } + } + + function handleClearSearch() { + setSearchInput('') + setSearch(null) + } + + // Sync local input with URL state + useEffect(() => { + setSearchInput(search || '') + }, [search]) + + const filteredThreads = useMemo(() => { + if (!search || !search.trim()) { + return threads + } + + const searchLower = search.toLowerCase() + return threads.filter((thread) => { + return ( + thread.title.toLowerCase().includes(searchLower) || + thread.user.toLowerCase().includes(searchLower) || + (thread.summary && thread.summary.toLowerCase().includes(searchLower)) + ) + }) + }, [threads, search]) + + const activeFilterCount = [productArea, stack].filter(Boolean).length + + return ( +
    + {/* Header */} +
    +
    +

    Unresolved threads

    +

    + Over the last 30 days, with data refreshed every 10 minutes. +

    +
    +
    + + {/* Channel Filters */} +
    +
    + {tabs.map((tab) => { + const isActive = currentTab === tab.id + const Icon = tab.icon + + return ( + + ) + })} +
    + +
    + {/* Search Input */} +
    + + setSearchInput(e.target.value)} + onKeyDown={handleSearchKeyDown} + className={cn('pl-10', searchInput && 'pr-10')} + /> + {searchInput && ( + + } + /> +
    +
    + + {hasMore && ( +
    + +
    + )} +
    + ) +} + +function ThreadRow({ + thread, + productArea, + search, +}: { + thread: ThreadRow + productArea: string | null + search: string | null +}) { + const [currentProductArea, setProductArea] = useQueryState( + 'product_area', + parseAsString.withOptions({ + shallow: false, + }) + ) + const [currentStack, setStack] = useQueryState( + 'stack', + parseAsString.withOptions({ + shallow: false, + }) + ) + + function handleProductAreaClick(area: string) { + if (currentProductArea === area) { + setProductArea(null) + } else { + setProductArea(area) + } + } + + function handleStackClick(tech: string) { + if (currentStack === tech) { + setStack(null) + } else { + setStack(tech) + } + } + + return ( + + {/* Thread title and product areas */} + +
    + {/* Channel icon */} +
    + {thread.channel === 'discord' && ( + + )} + {thread.channel === 'reddit' && ( + + )} + {thread.channel === 'github' && ( + + )} +
    +
    + {/* Thread title */} +

    + {highlightText(thread.title, search)} +

    + {/* Posted time and product areas */} +
    + {/* Posted time */} +

    {thread.posted}

    + {/* Product areas */} + {thread.product_areas.length > 0 && + (() => { + const filteredAreas = thread.product_areas.filter( + (area: string) => area !== 'Other' + ) + return filteredAreas.length > 0 ? ( +
    { + const row = e.currentTarget.closest('tr') + row?.classList.remove('group') + row?.classList.add('hovering-badge') + }} + onMouseLeave={(e) => { + const row = e.currentTarget.closest('tr') + row?.classList.add('group') + row?.classList.remove('hovering-badge') + }} + className="flex flex-wrap gap-x-1.5 gap-y-1 overflow-hidden" + > + {filteredAreas.map((area: string) => { + const isActive = productArea === area + return ( + + ) + })} +
    + ) : null + })()} +
    +
    +
    +
    + {/* Stack */} + +
    { + const row = e.currentTarget.closest('tr') + row?.classList.remove('group') + row?.classList.add('hovering-badge') + }} + onMouseLeave={(e) => { + const row = e.currentTarget.closest('tr') + row?.classList.add('group') + row?.classList.remove('hovering-badge') + }} + className="flex flex-wrap gap-x-1.5 gap-y-0.5 overflow-hidden" + > + {thread.stack.length > 0 ? ( + (() => { + const filteredStack = thread.stack.filter((tech: string) => tech !== 'Other') + + // Check if active stack is in the overflow section + const overflowStacks = filteredStack.slice(5) + const hasActiveInOverflow = currentStack && overflowStacks.includes(currentStack) + + return ( + <> + {filteredStack.slice(0, 5).map((tech: string) => { + const isActive = currentStack === tech + return ( + + ) + })} + {filteredStack.length > 5 && ( + + + + + +
    + {overflowStacks.map((tech: string) => { + const isActive = currentStack === tech + return ( + + ) + })} +
    +
    +
    + )} + + ) + })() + ) : ( +

    + )} +
    +
    + + {/* Replies */} + +
    + {thread.message_count !== null && thread.message_count !== undefined && ( + + )} +

    + {thread.message_count !== null && thread.message_count !== undefined + ? Math.max(0, thread.message_count - 1) + : '—'} +

    +
    + {/* Floating link */} + +
    +
    + ) +} + +function highlightText(text: string, searchTerm: string | null): ReactNode { + if (!searchTerm || !searchTerm.trim()) { + return text + } + + const searchLower = searchTerm.toLowerCase() + const textLower = text.toLowerCase() + const parts: ReactNode[] = [] + let lastIndex = 0 + let index = textLower.indexOf(searchLower, lastIndex) + + while (index !== -1) { + // Add text before the match + if (index > lastIndex) { + parts.push(text.slice(lastIndex, index)) + } + // Add the highlighted match + parts.push( + + {text.slice(index, index + searchTerm.length)} + + ) + lastIndex = index + searchTerm.length + index = textLower.indexOf(searchLower, lastIndex) + } + + // Add remaining text after the last match + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)) + } + + return parts.length > 0 ? <>{parts} : text +} diff --git a/apps/www/components/Contribute/UserProfile.tsx b/apps/www/components/Contribute/UserProfile.tsx new file mode 100644 index 0000000000000..86756c6fdd75f --- /dev/null +++ b/apps/www/components/Contribute/UserProfile.tsx @@ -0,0 +1,150 @@ +import Link from 'next/link' +import { notFound } from 'next/navigation' +import { Badge, Button } from 'ui' +import { DiscordIcon, GitHubIcon, RedditIcon } from '~/components/Contribute/Icons' +import { getUserActivity } from '~/data/contribute' +import type { ThreadRow } from '~/types/contribute' + +function ThreadCard({ thread }: { thread: ThreadRow }) { + return ( + +
    +
    + {thread.channel === 'discord' && } + {thread.channel === 'reddit' && } + {thread.channel === 'github' && } + {thread.channel} + + {thread.posted} +
    +

    {thread.title}

    + {thread.summary && ( +

    {thread.summary}

    + )} + {thread.product_areas.filter((area) => area !== 'Other').length > 0 && ( +
    + {thread.product_areas + .filter((area) => area !== 'Other') + .slice(0, 3) + .map((area) => ( + + {area} + + ))} + {thread.product_areas.filter((area) => area !== 'Other').length > 3 && ( + + +{thread.product_areas.filter((area) => area !== 'Other').length - 3} + + )} +
    + )} +
    + + ) +} + +interface Reply { + id: string + author: string | null + content: string | null + ts: string | null + external_activity_url: string | null + thread_key: string | null +} + +function ReplyCard({ reply, thread }: { reply: Reply; thread?: ThreadRow }) { + const platformName = thread?.channel + ? thread.channel.charAt(0).toUpperCase() + thread.channel.slice(1) + : 'platform' + + return ( +
    +
    + {thread && ( + + Reply to: {thread.title} + + )} +

    {reply.content}

    +
    + {reply.ts && ( + + {new Date(reply.ts).toLocaleString()} + + )} + {reply.external_activity_url && ( + + )} +
    +
    +
    + ) +} + +export async function UserProfile({ username }: { username: string }) { + const { threads, replies, replyThreads, stats } = await getUserActivity(username) + + if (threads.length === 0 && replies.length === 0) { + notFound() + } + + return ( +
    + {/* Header */} +
    +

    {username}

    +
    +
    + {stats.threadCount} + {stats.threadCount === 1 ? 'thread' : 'threads'} +
    + / +
    + {stats.replyCount} + {stats.replyCount === 1 ? 'reply' : 'replies'} +
    +
    +
    + + {/* Threads Section */} + {threads.length > 0 && ( +
    +

    Threads created

    +
    + {threads.map((thread) => ( + + ))} +
    +
    + )} + + {/* Replies Section */} + {replies.length > 0 && ( +
    +

    Recent replies

    +
    + {replies.map((reply) => { + const thread = replyThreads.find((t) => t.thread_key === reply.thread_key) + return + })} +
    +
    + )} +
    + ) +} diff --git a/apps/www/components/Contribute/markdownComponents.tsx b/apps/www/components/Contribute/markdownComponents.tsx new file mode 100644 index 0000000000000..b25a316984534 --- /dev/null +++ b/apps/www/components/Contribute/markdownComponents.tsx @@ -0,0 +1,30 @@ +import type { Components } from 'react-markdown' + +export const markdownComponents: Components = { + code: ({ node, inline, ...props }) => + inline ? ( + + ) : ( + + ), + pre: ({ node, ...props }) => ( +
    +  ),
    +  a: ({ node, ...props }) => (
    +    
    +  ),
    +  p: ({ node, ...props }) => 

    , + ul: ({ node, ordered, ...props }) =>

      , + ol: ({ node, ...props }) =>
        , +} diff --git a/apps/www/components/Forms/ApplyToSupaSquadForm.tsx b/apps/www/components/Forms/ApplyToSupaSquadForm.tsx index ac7e74a9991c4..67951fa8c9b2e 100644 --- a/apps/www/components/Forms/ApplyToSupaSquadForm.tsx +++ b/apps/www/components/Forms/ApplyToSupaSquadForm.tsx @@ -54,6 +54,8 @@ interface Track { interface Props { className?: string + title?: string + description?: string } const tracks: Track[] = [ @@ -114,12 +116,6 @@ const languagesSpoken: string[] = [ 'Other', ] -const headerContent = { - title: 'Apply to join SupaSquad', - description: - 'Join our community of passionate contributors and help shape the future of Supabase. Fill out the form below to apply.', -} - const FormContent = memo(function FormContent({ form, errors, @@ -599,7 +595,11 @@ const FormContent = memo(function FormContent({ ) }) -const ApplyToSupaSquadForm: FC = ({ className }) => { +const ApplyToSupaSquadForm: FC = ({ + className, + title = 'Apply to join SupaSquad', + description = 'Join our community of passionate contributors and help shape the future of Supabase. Fill out the form below to apply.', +}) => { const [honeypot, setHoneypot] = useState('') // field to prevent spam const [errors, setErrors] = useState<{ [key: string]: string }>({}) const [isSubmitting, setIsSubmitting] = useState(false) @@ -687,8 +687,8 @@ const ApplyToSupaSquadForm: FC = ({ className }) => { <>
        -

        {headerContent.title}

        -

        {headerContent.description}

        +

        {title}

        +

        {description}

        diff --git a/apps/www/data/Developers.tsx b/apps/www/data/Developers.tsx index d3db471799f5d..4df52e7085ef8 100644 --- a/apps/www/data/Developers.tsx +++ b/apps/www/data/Developers.tsx @@ -1,8 +1,7 @@ -import { Calendar, SquarePlus } from 'lucide-react' +import { Calendar, Pencil } from 'lucide-react' import { IconBriefcase2, IconChangelog, - IconDiscussions, IconDocumentation, IconGitHubSolid, IconIntegrations, @@ -44,7 +43,7 @@ export const data = { height="14.72" rx="1.92" stroke="currentColor" - stroke-width="1.28" + strokeWidth="1.28" /> , }, { text: 'Become a Partner', diff --git a/apps/www/data/contribute/index.ts b/apps/www/data/contribute/index.ts new file mode 100644 index 0000000000000..16a9aadaa8301 --- /dev/null +++ b/apps/www/data/contribute/index.ts @@ -0,0 +1,339 @@ +import { createClient } from '@supabase/supabase-js' +import type { + LeaderboardPeriod, + LeaderboardRow, + Thread, + ThreadRow, + ThreadSource, +} from '~/types/contribute' + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_CONTRIBUTE_URL as string +const supabasePublishableKey = process.env.NEXT_PUBLIC_SUPABASE_CONTRIBUTE_PUBLISHABLE_KEY as string + +function formatTimeAgo(date: Date): string { + const now = new Date() + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) + + if (diffInSeconds < 60) return `${diffInSeconds}s ago` + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago` + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago` + return `${Math.floor(diffInSeconds / 86400)}d ago` +} + +function normalizeSource(source: string | null): ThreadSource { + if (!source) return 'discord' + return source.toLowerCase().trim() as ThreadSource +} + +function mapThreadRowToThread(row: Thread): ThreadRow { + const firstMsgTime = row.first_msg_time ? new Date(row.first_msg_time) : null + const source = normalizeSource(row.source) + + return { + id: row.thread_id, + title: row.subject ?? row.title ?? '', + user: row.author, + channel: source, + conversation: row.conversation ?? '', + tags: row.product_areas ?? [], + product_areas: row.product_areas ?? [], + stack: row.stack ?? [], + posted: + firstMsgTime && !isNaN(firstMsgTime.getTime()) + ? formatTimeAgo(firstMsgTime) + : row.created_at + ? formatTimeAgo(new Date(row.created_at)) + : '', + source, + external_activity_url: row.external_activity_url ?? '#', + category: row.category, + sub_category: row.sub_category, + summary: row.summary, + thread_key: row.thread_key ?? null, + message_count: row.message_count ?? null, + } +} + +export async function getChannelCounts( + product_area?: string | string[], + stack?: string | string[], + search?: string +): Promise<{ all: number; discord: number; reddit: number; github: number }> { + const supabase = createClient(supabaseUrl, supabasePublishableKey) + + const sevenDaysAgo = new Date() + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 30) + const since = sevenDaysAgo.toISOString() + + let query = supabase.from('contribute_threads').select('source', { count: 'exact', head: false }) + + // When searching, don't apply time/status filters to allow finding any matching threads + if (!search || !search.trim()) { + query = query.gte('first_msg_time', since).in('status', ['unanswered', 'unresolved']) + } + + if (product_area) { + const areas = Array.isArray(product_area) ? product_area : [product_area] + query = query.overlaps('product_areas', areas) + } + + if (stack) { + const stacks = Array.isArray(stack) ? stack : [stack] + query = query.overlaps('stack', stacks) + } + + if (search && search.trim()) { + const trimmedSearch = search.trim() + const searchTerm = `%${trimmedSearch}%` + query = query.ilike('subject', searchTerm) + } + + const { data, error } = await query + + if (error) { + console.error('Error fetching channel counts:', error) + return { all: 0, discord: 0, reddit: 0, github: 0 } + } + + const threads = (data ?? []) as Array<{ source: string }> + const discord = threads.filter((t) => normalizeSource(t.source) === 'discord').length + const reddit = threads.filter((t) => normalizeSource(t.source) === 'reddit').length + const github = threads.filter((t) => normalizeSource(t.source) === 'github').length + + return { + all: threads.length, + discord, + reddit, + github, + } +} + +export async function getUnansweredThreads( + product_area?: string | string[], + channel?: string, + stack?: string | string[], + search?: string, + offset: number = 0, + limit: number = 100 +): Promise { + const supabase = createClient(supabaseUrl, supabasePublishableKey) + + const sevenDaysAgo = new Date() + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 30) + const since = sevenDaysAgo.toISOString() + + let query = supabase + .from('contribute_threads') + .select( + 'thread_id, subject, status, author, external_activity_url, created_at, source, product_areas, stack, category, sub_category, summary, first_msg_time, message_count' + ) + + // When searching, don't apply time/status filters to allow finding any matching threads + if (!search || !search.trim()) { + query = query.gte('first_msg_time', since).in('status', ['unanswered', 'unresolved']) + } + // When searching, skip status filter to find threads regardless of status + + if (product_area) { + const areas = Array.isArray(product_area) ? product_area : [product_area] + query = query.overlaps('product_areas', areas) + } + + if (stack) { + const stacks = Array.isArray(stack) ? stack : [stack] + query = query.overlaps('stack', stacks) + } + + if (channel && channel !== 'all') { + console.log('channel', channel) + query = query.eq('source', channel.toLowerCase()) + } + + if (search && search.trim()) { + const trimmedSearch = search.trim() + const searchTerm = `%${trimmedSearch}%` + query = query.ilike('subject', searchTerm) + } + + const { data, error } = await query + // .gte("created_at", since) + .order('first_msg_time', { ascending: false }) + .range(offset, offset + limit - 1) + + if (error) { + console.error('Error fetching threads:', error) + console.error('Query details:', { search, product_area, channel, stack }) + throw error + } + + const threads = (data ?? []) as Thread[] + return threads.map(mapThreadRowToThread) +} + +export async function getThreadById(id: string): Promise { + const supabase = createClient(supabaseUrl, supabasePublishableKey) + + const { data, error } = await supabase + .from('contribute_threads') + .select( + 'thread_id, subject, status, author, conversation, external_activity_url, created_at, source, product_areas, stack, category, sub_category, summary, first_msg_time, message_count, thread_key' + ) + .eq('thread_id', id) + .single() + + if (error) { + console.error('Error fetching thread:', error) + return null + } + + if (!data) { + return null + } + + return mapThreadRowToThread(data as Thread) +} + +export async function getThreadRepliesById(thread_key: string | null) { + const supabase = createClient(supabaseUrl, supabasePublishableKey) + + if (!thread_key) { + return { question: null, replies: [] } + } + + const { data, error } = await supabase + .from('contribute_posts') + .select('id, author, content, ts, external_activity_url, thread_key, kind') + .eq('thread_key', thread_key) + .in('kind', ['question', 'reply']) + .order('ts', { ascending: true }) + + if (error) { + console.error('Error fetching thread posts:', error) + return { question: null, replies: [] } + } + + const question = data?.find((post) => post.kind === 'question') || null + const replies = data?.filter((post) => post.kind === 'reply') || [] + + return { question, replies } +} + +export async function getAllProductAreas(): Promise { + const supabase = createClient(supabaseUrl, supabasePublishableKey) + + const { data, error } = await supabase + .from('contribute_threads') + .select('product_areas') + .in('status', ['unanswered', 'unresolved']) + + if (error) { + console.error('Error fetching product areas:', error) + return [] + } + + const areas = new Set() + data?.forEach((row: { product_areas: string[] | null }) => { + if (row.product_areas && Array.isArray(row.product_areas)) { + row.product_areas.forEach((area: string) => areas.add(area)) + } + }) + + return Array.from(areas).sort() +} + +export async function getAllStacks(): Promise { + const supabase = createClient(supabaseUrl, supabasePublishableKey) + + const { data, error } = await supabase + .from('contribute_threads') + .select('stack') + .in('status', ['unanswered', 'unresolved']) + + if (error) { + console.error('Error fetching stacks:', error) + return [] + } + + const stacks = new Set() + data?.forEach((row: { stack: string[] | null }) => { + if (row.stack && Array.isArray(row.stack)) { + row.stack.forEach((stack: string) => stacks.add(stack)) + } + }) + + return Array.from(stacks).sort() +} + +export const LEADERBOARD_PERIODS = ['all', 'year', 'quarter', 'month', 'week', 'today'] as const + +export async function getLeaderboard( + period: (typeof LEADERBOARD_PERIODS)[number] +): Promise { + const supabase = createClient(supabaseUrl, supabasePublishableKey) + const { data, error } = await supabase.rpc('get_leaderboard', { + period: period, + }) + + if (error) throw error + return data ?? [] +} + +export async function getUserActivity(author: string) { + const supabase = createClient(supabaseUrl, supabasePublishableKey) + + // Get user's threads + const { data: threads, error: threadsError } = await supabase + .from('contribute_threads') + .select( + 'thread_id, subject, status, author, external_activity_url, created_at, source, product_areas, stack, category, sub_category, summary, first_msg_time, message_count, thread_key' + ) + .eq('author', author) + .order('first_msg_time', { ascending: false }) + .limit(50) + + if (threadsError) { + console.error('Error fetching user threads:', threadsError) + } + + // Get user's replies + const { data: replies, error: repliesError } = await supabase + .from('contribute_posts') + .select('id, author, content, ts, external_activity_url, thread_key, kind') + .eq('author', author) + .eq('kind', 'reply') + .order('ts', { ascending: false }) + .limit(50) + + if (repliesError) { + console.error('Error fetching user replies:', repliesError) + } + + // Get thread information for replies + const threadKeys = replies?.map((r) => r.thread_key).filter(Boolean) ?? [] + let replyThreads: Thread[] = [] + + if (threadKeys.length > 0) { + const { data: replyThreadsData, error: replyThreadsError } = await supabase + .from('contribute_threads') + .select( + 'thread_id, subject, status, author, external_activity_url, created_at, source, product_areas, stack, category, sub_category, summary, first_msg_time, message_count, thread_key' + ) + .in('thread_key', threadKeys) + + if (replyThreadsError) { + console.error('Error fetching reply threads:', replyThreadsError) + } else { + replyThreads = (replyThreadsData ?? []) as Thread[] + } + } + + return { + threads: threads ? threads.map((t) => mapThreadRowToThread(t as Thread)) : [], + replies: replies ?? [], + replyThreads: replyThreads.map((t) => mapThreadRowToThread(t)), + stats: { + threadCount: threads?.length ?? 0, + replyCount: replies?.length ?? 0, + }, + } +} diff --git a/apps/www/data/solutions/solutions.utils.tsx b/apps/www/data/solutions/solutions.utils.tsx index a80b0ba41b6dd..085e1e1ba37b5 100644 --- a/apps/www/data/solutions/solutions.utils.tsx +++ b/apps/www/data/solutions/solutions.utils.tsx @@ -66,7 +66,7 @@ export interface Feature { export interface FeaturesSection { id: string label?: string - heading: JSX.Element + heading: string | JSX.Element subheading?: string features: Feature[] // { diff --git a/apps/www/lib/contribute.types.ts b/apps/www/lib/contribute.types.ts new file mode 100644 index 0000000000000..bd01b24e60338 --- /dev/null +++ b/apps/www/lib/contribute.types.ts @@ -0,0 +1,318 @@ +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[] + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: '13.0.5' + } + public: { + Tables: { + contribute_posts: { + Row: { + activity_type: string | null + author: string | null + common_room_activity_url: string | null + content: string | null + external_activity_url: string | null + id: string + kind: string | null + raw: Json | null + service_name: string | null + source_id: string | null + tags: Json | null + thread_key: string | null + title: string | null + topics: Json | null + ts: string | null + } + Insert: { + activity_type?: string | null + author?: string | null + common_room_activity_url?: string | null + content?: string | null + external_activity_url?: string | null + id: string + kind?: string | null + raw?: Json | null + service_name?: string | null + source_id?: string | null + tags?: Json | null + thread_key?: string | null + title?: string | null + topics?: Json | null + ts?: string | null + } + Update: { + activity_type?: string | null + author?: string | null + common_room_activity_url?: string | null + content?: string | null + external_activity_url?: string | null + id?: string + kind?: string | null + raw?: Json | null + service_name?: string | null + source_id?: string | null + tags?: Json | null + thread_key?: string | null + title?: string | null + topics?: Json | null + ts?: string | null + } + Relationships: [] + } + contribute_threads: { + Row: { + author: string + category: string | null + conversation: string + created_at: string | null + entities: string[] | null + external_activity_url: string | null + first_msg_time: string | null + id: string + labels: string[] | null + last_msg_time: string | null + message_count: number | null + metadata: Json | null + processed_at: string | null + product_areas: string[] | null + resolved_by: string | null + sentiment: string | null + source: string | null + stack: string[] | null + status: string | null + sub_category: string | null + subject: string | null + subject_embedding: string | null + summary: string | null + thread_id: string + thread_key: string | null + title: string + topic: string | null + topic_embedding: string | null + topic_id: string | null + updated_at: string | null + } + Insert: { + author: string + category?: string | null + conversation: string + created_at?: string | null + entities?: string[] | null + external_activity_url?: string | null + first_msg_time?: string | null + id?: string + labels?: string[] | null + last_msg_time?: string | null + message_count?: number | null + metadata?: Json | null + processed_at?: string | null + product_areas?: string[] | null + resolved_by?: string | null + sentiment?: string | null + source?: string | null + stack?: string[] | null + status?: string | null + sub_category?: string | null + subject?: string | null + subject_embedding?: string | null + summary?: string | null + thread_id: string + thread_key?: string | null + title: string + topic?: string | null + topic_embedding?: string | null + topic_id?: string | null + updated_at?: string | null + } + Update: { + author?: string + category?: string | null + conversation?: string + created_at?: string | null + entities?: string[] | null + external_activity_url?: string | null + first_msg_time?: string | null + id?: string + labels?: string[] | null + last_msg_time?: string | null + message_count?: number | null + metadata?: Json | null + processed_at?: string | null + product_areas?: string[] | null + resolved_by?: string | null + sentiment?: string | null + source?: string | null + stack?: string[] | null + status?: string | null + sub_category?: string | null + subject?: string | null + subject_embedding?: string | null + summary?: string | null + thread_id?: string + thread_key?: string | null + title?: string + topic?: string | null + topic_embedding?: string | null + topic_id?: string | null + updated_at?: string | null + } + Relationships: [] + } + } + Views: { + v_contribute_pending_posts_to_threads: { + Row: { + activity_type: string | null + author: string | null + common_room_activity_url: string | null + content: string | null + external_activity_url: string | null + id: string | null + kind: string | null + raw: Json | null + service_name: string | null + source_id: string | null + tags: Json | null + thread_key: string | null + title: string | null + topics: Json | null + ts: string | null + } + Relationships: [] + } + } + Functions: { + reprocess_stale_threads: { Args: never; Returns: undefined } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views']) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + ? (DefaultSchema['Tables'] & DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema['Enums'] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] + ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema['CompositeTypes'] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes'] + ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: {}, + }, +} as const diff --git a/apps/www/public/images/contribute/ask-supabase.jpg b/apps/www/public/images/contribute/ask-supabase.jpg new file mode 100644 index 0000000000000..b8e01fea95140 Binary files /dev/null and b/apps/www/public/images/contribute/ask-supabase.jpg differ diff --git a/apps/www/types/contribute.ts b/apps/www/types/contribute.ts new file mode 100644 index 0000000000000..576ced0f3a301 --- /dev/null +++ b/apps/www/types/contribute.ts @@ -0,0 +1,31 @@ +import { Tables } from '~/lib/contribute.types' + +export type Thread = Tables<'contribute_threads'> + +export type ThreadSource = 'discord' | 'reddit' | 'github' + +export interface ThreadRow { + id: string + title: string + conversation: string + user: string + channel: ThreadSource + tags: string[] + product_areas: string[] + stack: string[] + posted: string + source: ThreadSource + external_activity_url: string + category: string | null + sub_category: string | null + summary: string | null + thread_key: string | null + message_count: number | null +} + +export type LeaderboardPeriod = 'all' | 'year' | 'quarter' | 'month' | 'week' | 'today' + +export type LeaderboardRow = { + author: string | null // sometimes author can be null + reply_count: number // bigint comes back as number via supabase-js +} diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index 6205f41132dbb..07bf296cfbab4 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -280,7 +280,6 @@ export const PageTelemetry = ({ if ( (router?.isReady ?? true) && hasAcceptedConsent && - featureFlags.hasLoaded && !hasSentInitialPageTelemetryRef.current ) { const cookies = document.cookie.split(';') @@ -316,7 +315,7 @@ export const PageTelemetry = ({ hasSentInitialPageTelemetryRef.current = true } - }, [router?.isReady, hasAcceptedConsent, featureFlags.hasLoaded, slug, ref]) + }, [router?.isReady, hasAcceptedConsent, slug, ref]) useEffect(() => { // For pages router