diff --git a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx index 94f25d8ff2215..20ec963cd225c 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/AccessTokenList.tsx @@ -15,9 +15,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, Skeleton, - Tooltip, - TooltipContent, - TooltipTrigger, } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { @@ -26,8 +23,24 @@ import { TableCell, TableHead, TableHeader, + TableHeadSort, TableRow, } from 'ui/src/components/shadcn/ui/table' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' +import { parseAsStringLiteral, useQueryState } from 'nuqs' + +const ACCESS_TOKEN_SORT_VALUES = [ + 'created_at:asc', + 'created_at:desc', + 'last_used_at:asc', + 'last_used_at:desc', + 'expires_at:asc', + 'expires_at:desc', +] as const + +type AccessTokenSort = (typeof ACCESS_TOKEN_SORT_VALUES)[number] +type AccessTokenSortColumn = AccessTokenSort extends `${infer Column}:${string}` ? Column : unknown +type AccessTokenSortOrder = AccessTokenSort extends `${string}:${infer Order}` ? Order : unknown const RowLoading = () => ( @@ -49,17 +62,31 @@ const RowLoading = () => ( ) -const tableHeaderClass = 'text-left font-mono uppercase text-xs text-foreground-lighter h-auto py-2' +const tableHeaderClass = 'text-left font-mono uppercase text-xs text-foreground-lighter py-2' + +interface TableContainerProps { + children: React.ReactNode + sort: AccessTokenSort + onSortChange: (column: AccessTokenSortColumn) => void +} -const TableContainer = ({ children }: { children: React.ReactNode }) => ( +const TableContainer = ({ children, sort, onSortChange }: TableContainerProps) => ( Token - Last used - Expires + + + Last used + + + + + Expires + + @@ -77,6 +104,10 @@ export interface AccessTokenListProps { export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTokenListProps) => { const [isOpen, setIsOpen] = useState(false) const [token, setToken] = useState(undefined) + const [sort, setSort] = useQueryState( + 'sort', + parseAsStringLiteral(ACCESS_TOKEN_SORT_VALUES).withDefault('created_at:desc') + ) const { data: tokens, error, isPending: isLoading, isError } = useAccessTokensQuery() @@ -91,25 +122,72 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo }, }) + const handleSortChange = (column: AccessTokenSortColumn) => { + const [currentCol, currentOrder] = sort.split(':') as [ + AccessTokenSortColumn, + AccessTokenSortOrder, + ] + if (currentCol === column) { + if (currentOrder === 'asc') { + setSort(`${column}:desc` as AccessTokenSort) + } else { + setSort('created_at:desc') + } + } else { + setSort(`${column}:asc` as AccessTokenSort) + } + } + const onDeleteToken = async (tokenId: number) => { deleteToken({ id: tokenId }) } const filteredTokens = useMemo(() => { - return !searchString + const filtered = !searchString ? tokens : tokens?.filter((token) => { return token.name.toLowerCase().includes(searchString.toLowerCase()) }) - }, [tokens, searchString]) + + if (!filtered) return filtered + + const [sortCol, sortOrder] = sort.split(':') as [AccessTokenSortColumn, AccessTokenSortOrder] + const orderMultiplier = sortOrder === 'asc' ? 1 : -1 + + return [...filtered].sort((a, b) => { + if (sortCol === 'created_at') { + return ( + (new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) * orderMultiplier + ) + } + if (sortCol === 'last_used_at') { + if (!a.last_used_at && !b.last_used_at) return 0 + if (!a.last_used_at) return 1 + if (!b.last_used_at) return -1 + return ( + (new Date(a.last_used_at).getTime() - new Date(b.last_used_at).getTime()) * + orderMultiplier + ) + } + if (sortCol === 'expires_at') { + if (!a.expires_at && !b.expires_at) return 0 + if (!a.expires_at) return 1 + if (!b.expires_at) return -1 + return ( + (new Date(a.expires_at).getTime() - new Date(b.expires_at).getTime()) * orderMultiplier + ) + } + return 0 + }) + }, [tokens, searchString, sort]) const empty = filteredTokens?.length === 0 && !isLoading if (isError) { return ( - + - + + @@ -132,9 +210,9 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo if (empty) { return ( - + - +

No access tokens found

You do not have any tokens created yet @@ -147,7 +225,7 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo return ( <> - + {filteredTokens?.map((x) => { return ( @@ -155,46 +233,32 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo

{x.name}

-

+

{x.token_alias}

- -

- {x.last_used_at ? ( - - {dayjs(x.last_used_at).format('DD MMM YYYY')} - - Last used on {dayjs(x.last_used_at).format('DD MMM, YYYY HH:mm:ss')} - - - ) : ( - 'Never used' - )} -

+ + {x.last_used_at ? ( + + ) : ( + 'Never used' + )} - + {x.expires_at ? ( dayjs(x.expires_at).isBefore(dayjs()) ? ( - - -

Expired

-
- - Expired on {dayjs(x.expires_at).format('DD MMM, YYYY HH:mm:ss')} - -
+ ) : ( - - -

- {dayjs(x.expires_at).format('DD MMM YYYY')} -

-
- - Expires on {dayjs(x.expires_at).format('DD MMM, YYYY HH:mm:ss')} - -
+ ) ) : (

Never

@@ -205,9 +269,9 @@ export const AccessTokenList = ({ searchString = '', onDeleteSuccess }: AccessTo + + + {filteredBlogs.length > 0 ? ( +
    + {filteredBlogs.map((blog: PostTypes, idx: number) => + isList ? ( +
    + +
    + ) : ( +
    + +
    + ) + )} +
+ ) : ( +

+ {searchTerm + ? 'No posts found matching your search.' + : 'No posts found by this author.'} +

+ )} + + + + ) +} diff --git a/apps/www/app/blog/authors/[author]/page.tsx b/apps/www/app/blog/authors/[author]/page.tsx new file mode 100644 index 0000000000000..29297b45bb7de --- /dev/null +++ b/apps/www/app/blog/authors/[author]/page.tsx @@ -0,0 +1,71 @@ +import type { Metadata } from 'next' + +import blogAuthors from 'lib/authors.json' +import { getAllCMSPosts } from 'lib/get-cms-posts' +import { getSortedPosts } from 'lib/posts' +import type PostTypes from 'types/post' +import AuthorClient from './AuthorClient' + +type Params = { author: string } + +// Build a lookup map from any identifier (author_id or username) to canonical author_id +const authorIdLookup = new Map() +for (const author of blogAuthors) { + authorIdLookup.set(author.author_id, author.author_id) + if ('username' in author && author.username) { + authorIdLookup.set(author.username, author.author_id) + } +} + +// Normalize any author identifier to the canonical author_id +function toCanonicalAuthorId(identifier: string): string { + return authorIdLookup.get(identifier) ?? identifier +} + +export async function generateStaticParams() { + return blogAuthors.map((author) => ({ author: author.author_id })) +} + +export const revalidate = 30 +export const dynamic = 'force-static' + +export async function generateMetadata({ + params: paramsPromise, +}: { + params: Promise +}): Promise { + const params = await paramsPromise + const author = blogAuthors.find((a) => a.author_id === params.author) + + return { + title: author ? `Blog | ${author.author}` : 'Blog | Author', + description: author ? `Blog posts by ${author.author}` : 'Latest news from the Supabase team.', + } +} + +export default async function AuthorPage({ params: paramsPromise }: { params: Promise }) { + const params = await paramsPromise + const authorId = params.author + + const author = blogAuthors.find((a) => a.author_id === authorId) ?? null + + // Get static posts where author field contains this author_id (normalize identifiers) + const staticPosts = getSortedPosts({ directory: '_blog', limit: 0 }).filter((post: any) => { + const postAuthors = post.author?.split(',').map((a: string) => a.trim()) || [] + return postAuthors.some((a: string) => toCanonicalAuthorId(a) === authorId) + }) + + // Get CMS posts by this author (normalize identifiers) + const allCmsPosts = await getAllCMSPosts({}) + const cmsPosts = allCmsPosts.filter((post: any) => { + return post.authors?.some( + (a: any) => toCanonicalAuthorId(a.author_id ?? a.username ?? '') === authorId + ) + }) + + const blogs = [...(staticPosts as any[]), ...(cmsPosts as any[])].sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() + ) as unknown as PostTypes[] + + return +} diff --git a/apps/www/components/Blog/BlogPostRenderer.tsx b/apps/www/components/Blog/BlogPostRenderer.tsx index aa9864665f0c2..2b73b3a36ceef 100644 --- a/apps/www/components/Blog/BlogPostRenderer.tsx +++ b/apps/www/components/Blog/BlogPostRenderer.tsx @@ -200,8 +200,8 @@ const BlogPostRenderer = ({

{(blogMetaData as any).readingTime}

{authors.length > 0 && ( -
-
+
+
{authors.map((author, i: number) => { // Handle both static and CMS author image formats const authorImageUrl = @@ -209,13 +209,14 @@ const BlogPostRenderer = ({ ? author.author_image_url : (author.author_image_url as { url: string })?.url || '' + const authorId = + (author as any).author_id || + (author as any).username || + author.author.toLowerCase().replace(/\s+/g, '_') + return (
- +
{authorImageUrl && (
diff --git a/apps/www/components/OpenSourceSection.tsx b/apps/www/components/OpenSourceSection.tsx new file mode 100644 index 0000000000000..781a0169c7b86 --- /dev/null +++ b/apps/www/components/OpenSourceSection.tsx @@ -0,0 +1,57 @@ +import Link from 'next/link' +import { Button } from 'ui' +import SectionContainer from './Layouts/SectionContainer' +import { useSendTelemetryEvent } from '~/lib/telemetry' +import { kFormatter } from '~/lib/helpers' +import staticContent from '.generated/staticContent/_index.json' + +const OpenSourceSection = () => { + const sendTelemetryEvent = useSendTelemetryEvent() + + return ( + +

Open source from day one

+

+ Supabase is built in the open because we believe great developer tools should be + transparent, inspectable, and owned by the community. Read, contribute, self-host. You're + never locked in, and always in control. +

+
+ + Top 100 GitHub repos +
+
+ ) +} + +export default OpenSourceSection diff --git a/apps/www/data/solutions/developers.tsx b/apps/www/data/solutions/developers.tsx index 31a440f5da5f3..7749403a4dbb1 100644 --- a/apps/www/data/solutions/developers.tsx +++ b/apps/www/data/solutions/developers.tsx @@ -823,83 +823,22 @@ The output should use the following instructions: apiExamples: [ { lang: 'json', - title: 'macOS', + title: 'Hosted', code: `{ -"mcpServers": { - "supabase": { - "command": "npx", - "args": [ - "-y", - "@supabase/mcp-server-supabase@latest", - "--read-only", - "--project-ref=" - ], - "env": { - "SUPABASE_ACCESS_TOKEN": "" - } - } -} -}`, - }, - { - lang: 'json', - title: 'Windows', - code: `{ -"mcpServers": { - "supabase": { - "command": "cmd", - "args": [ - "/c", - "npx", - "-y", - "@supabase/mcp-server-supabase@latest", - "--read-only", - "--project-ref=" - ], - "env": { - "SUPABASE_ACCESS_TOKEN": "" - } - } -} -}`, - }, - { - lang: 'json', - title: 'Windows (WSL)', - code: `{ -"mcpServers": { - "supabase": { - "command": "wsl", - "args": [ - "npx", - "-y", - "@supabase/mcp-server-supabase@latest", - "--read-only", - "--project-ref=" - ], - "env": { - "SUPABASE_ACCESS_TOKEN": "" + "mcpServers": { + "supabase": { + "url": "https://mcp.supabase.com/mcp" } } -} }`, }, { lang: 'json', - title: 'Linux', + title: 'CLI', code: `{ "mcpServers": { "supabase": { - "command": "npx", - "args": [ - "-y", - "@supabase/mcp-server-supabase@latest", - "--read-only", - "--project-ref=" - ], - "env": { - "SUPABASE_ACCESS_TOKEN": "" - } + "url": "http://localhost:54321/mcp" } } }`, diff --git a/apps/www/lib/helpers.tsx b/apps/www/lib/helpers.tsx index fb70995ed9c05..804a065a6f7b0 100644 --- a/apps/www/lib/helpers.tsx +++ b/apps/www/lib/helpers.tsx @@ -89,6 +89,38 @@ export const startCase = (string: string): string => { * @param options The options object * @returns Returns the new debounced function */ +/** + * Formats numbers into thousands (K) notation with intelligent decimal handling. + * Returns raw number string for values < 1000 to avoid "0K"/"0.xK" outputs. + * @param value The value to format (number, string, null, or undefined) + * @returns Formatted string (e.g., "999", "1K", "12.5K") + */ +export const kFormatter = (value: number | string | null | undefined): string => { + // Coerce to safe number, guarding against NaN + const num = Number(value ?? 0) + const safeNum = isNaN(num) ? 0 : num + + // Early return for values less than 1000 + if (safeNum < 1000) { + return String(Math.floor(safeNum)) + } + + const kFormat = Math.floor(safeNum / 1000) + const lastTwoDigits = safeNum % 1000 + + const decimalPart = Math.floor((lastTwoDigits % 100) / 10) + const hundreds = Math.floor(lastTwoDigits / 100) + + const isAlmostNextThousand = decimalPart >= 8 && hundreds >= 9 + + const showDecimals = + (!isAlmostNextThousand && hundreds >= 1) || (hundreds === 0 && decimalPart >= 8) + + return showDecimals + ? `${kFormat}.${decimalPart >= 8 ? hundreds + 1 : hundreds}K` + : `${isAlmostNextThousand ? kFormat + 1 : kFormat}K` +} + export const debounce = any>( func: T, wait: number, diff --git a/apps/www/pages/index.tsx b/apps/www/pages/index.tsx index a51d8e6a6a5fa..e466d15868f0b 100644 --- a/apps/www/pages/index.tsx +++ b/apps/www/pages/index.tsx @@ -10,6 +10,7 @@ const CustomerStories = dynamic(() => import('components/CustomerStories')) const BuiltWithSupabase = dynamic(() => import('components/BuiltWithSupabase')) const DashboardFeatures = dynamic(() => import('~/components/DashboardFeatures')) const TwitterSocialSection = dynamic(() => import('~/components/TwitterSocialSection')) +const OpenSourceSection = dynamic(() => import('~/components/OpenSourceSection')) const CTABanner = dynamic(() => import('components/CTABanner/index')) const Index = () => { @@ -25,6 +26,7 @@ const Index = () => { + )