Files
supabase/apps/www/components/Contribute/UnansweredThreadsTable.tsx
Danny White 476d4a5851 refactor(ui): drop redundant Button variant="default" props (#50161)
## What kind of change does this PR introduce?

Mechanical cleanup on top of the Button default-variant change (#50160).

## What is the current behavior?

Many callsites still pass `variant="default"` even though that is now
the component default.

## What is the new behavior?

Removes redundant static `variant="default"` from legacy `Button` and
`ButtonTooltip` callsites. Keeps explicit defaults where they document
the API:

- `button-default.tsx` and `button-sizes.tsx` demos
- `DocsButton`, which pins neutral styling at the wrapper boundary

## To test

Studio:

- [Auth → Rate
Limits](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/auth/rate-limits):
dirty the form so Cancel appears; Cancel stays neutral, Save stays green
- [Project Settings → API
Keys](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/settings/api-keys):
`DocsButton` in the header actions stays neutral

Design system:

- [Design system →
Button](https://design-system-git-dnywh-dc924ac1-supabase.vercel.app/design-system/docs/components/button):
`button-default` / `button-sizes` still show explicit default styling;
Primary (green) is restricted to the Primary section (and `asChild`)

WWW:

- [www → Brand
assets](https://zone-www-dot-com-git-dnywh-dc924ac1-supabase.vercel.app/brand-assets):
Download logo kit / Download button kit stay neutral
2026-09-11 17:05:26 +10:00

638 lines
21 KiB
TypeScript

'use client'
import type { ThreadRow } from '~/types/contribute'
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,
Popover,
PopoverContent,
PopoverTrigger,
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { FilterPopover } from './FilterPopover'
import { ChannelIcon, DiscordIcon, GitHubIcon, RedditIcon } from './Icons'
interface TabConfig {
id: string
label: string
icon?: React.ComponentType<{ className?: string }>
iconColor?: string
}
function CountSkeleton() {
return <span className="inline-block min-w-[24px] h-5 bg-surface-300 animate-pulse rounded-sm" />
}
function ThreadsTable({
threads,
productArea,
search,
}: {
threads: ThreadRow[]
productArea: string | null
search: string | null
}) {
return (
<Card>
<Table className="w-full mt-0">
<TableHeader className="sr-only">
<TableRow>
<TableHead>Thread</TableHead>
<TableHead>Stack</TableHead>
<TableHead>Replies</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{threads.length === 0 ? (
<TableRow className="[&>td]:hover:bg-inherit">
<TableCell colSpan={3} className="text-center text-foreground-lighter py-6">
No threads found
</TableCell>
</TableRow>
) : (
threads.map((thread) => (
<ThreadRow
key={thread.id}
thread={thread}
productArea={productArea}
search={search}
/>
))
)}
</TableBody>
<TableCaption>
Showing {threads.length} {threads.length === 1 ? 'thread' : 'threads'}
</TableCaption>
</Table>
</Card>
)
}
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<HTMLInputElement>) {
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 (
<section className="w-full max-w-7xl mx-auto px-4 py-16 relative">
{/* Header */}
<header className="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-8">
<div className="flex flex-col gap-1">
<h2 className="text-xl text-foreground">Unresolved threads</h2>
<p className="text-foreground-lighter">
Over the last 30 days, with data refreshed every 10 minutes.
</p>
</div>
</header>
{/* Channel Filters */}
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4 mb-4">
<div className="flex items-center flex-wrap gap-x-1.5 overflow-x-auto">
{tabs.map((tab) => {
const isActive = currentTab === tab.id
const Icon = tab.icon
return (
<Button
key={tab.id}
variant={isActive ? 'default' : 'dashed'}
size="tiny"
onClick={() => handleTabChange(tab.id)}
icon={
Icon ? (
<Icon
className={cn(
'h-4 w-4',
isActive ? tab.iconColor : 'text-foreground-lighter'
)}
/>
) : undefined
}
className={cn('w-fit justify-start', isActive && 'bg-surface-300')}
>
<span className="flex items-center gap-1.5">
{tab.label}
<span className={cn('text-xs pl-1.5 min-w-[24px] text-center tabular-nums')}>
{channelCounts[tab.id as keyof typeof channelCounts]}
</span>
</span>
</Button>
)
})}
</div>
<div className="flex items-center gap-x-1.5 flex-1 md:justify-end w-full md:w-auto">
{/* Search Input */}
<form onSubmit={handleSearchSubmit} className="relative md:max-w-xs w-full flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-foreground-lighter" />
<Input
type="text"
size="tiny"
placeholder="Search threads by title..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={handleSearchKeyDown}
className={cn('pl-10', searchInput && 'pr-10')}
/>
{searchInput && (
<Button
variant="text"
size="tiny"
icon={<X className="h-4 w-4" />}
onClick={handleClearSearch}
className="absolute right-2 px-1 top-1/2 transform -translate-y-1/2"
aria-label="Clear search"
/>
)}
</form>
{/* Filter Button */}
<FilterPopover
allProductAreas={allProductAreas}
allStacks={allStacks}
trigger={
<Button icon={<Filter size={12} />}>
<span className="flex items-center gap-2">
Filters
{activeFilterCount > 0 && (
<div className="flex items-center justify-center text-[10px] font-medium w-4 h-4 rounded-full text-center bg-black dark:bg-white text-contrast">
{activeFilterCount}
</div>
)}
</span>
</Button>
}
/>
</div>
</div>
<ThreadsTable threads={filteredThreads} productArea={productArea} search={search} />
{hasMore && (
<div className="flex justify-center mt-4">
<Button
size="tiny"
onClick={handleLoadMore}
loading={isLoadingMore}
disabled={isLoadingMore}
>
Load more
</Button>
</div>
)}
</section>
)
}
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 (
<TableRow className="relative group [&.hovering-badge>td]:hover:bg-transparent!">
{/* Thread title and product areas */}
<TableCell className="w-auto max-w-[600px]">
<div className="flex items-center gap-3 overflow-hidden">
{/* Channel icon */}
<div className="flex items-center justify-center bg-surface-200 h-10 w-10 rounded-md shrink-0">
<ChannelIcon channel={thread.channel} />
</div>
<div className="min-w-0 flex-1 flex flex-col gap-y-0.5">
{/* Thread title */}
<h3 className="text-base text-foreground truncate transition-[text-decoration] duration-100 underline-offset-2">
{highlightText(thread.title, search)}
</h3>
{/* Posted time and product areas */}
<div className="flex flex-row items-baseline gap-2 overflow-hidden">
{/* Posted time */}
<p className="text-xs text-foreground-lighter whitespace-nowrap">{thread.posted}</p>
{/* Product areas */}
{thread.product_areas.length > 0 &&
(() => {
const filteredAreas = thread.product_areas.filter(
(area: string) => area !== 'Other'
)
return filteredAreas.length > 0 ? (
<div
onMouseEnter={(e) => {
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 (
<button
tabIndex={0}
key={area}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleProductAreaClick(area)
}}
type="button"
className="relative z-10"
>
<Badge
variant={isActive ? 'success' : 'default'}
className="transition-all duration-150 hover:bg-brand-300/30 dark:hover:bg-brand-300/80"
>
{area}
</Badge>
</button>
)
})}
</div>
) : null
})()}
</div>
</div>
</div>
</TableCell>
{/* Stack */}
<TableCell className="w-[300px]">
<div
onMouseEnter={(e) => {
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 (
<button
tabIndex={0}
key={tech}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleStackClick(tech)
}}
type="button"
className="relative z-10"
>
<Badge
variant={isActive ? 'success' : 'default'}
className="transition-all duration-150 hover:bg-brand-300/30 dark:hover:bg-brand-300/80"
>
{tech}
</Badge>
</button>
)
})}
{filteredStack.length > 5 && (
<Popover>
<PopoverTrigger asChild>
<button
tabIndex={0}
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onMouseEnter={(e) => {
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="relative z-10"
>
<Badge
variant={hasActiveInOverflow ? 'success' : 'default'}
className="transition-all duration-150 hover:bg-brand-200/50 dark:hover:bg-brand-300/80"
>
+ {filteredStack.length - 5}
</Badge>
</button>
</PopoverTrigger>
<PopoverContent className="max-w-[300px] p-3">
<div className="flex flex-wrap gap-2">
{overflowStacks.map((tech: string) => {
const isActive = currentStack === tech
return (
<button
tabIndex={0}
key={tech}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleStackClick(tech)
}}
type="button"
>
<Badge
variant={isActive ? 'success' : 'default'}
className="transition-all duration-150 hover:bg-brand-200/50 hover:border-brand-300/50"
>
{tech}
</Badge>
</button>
)
})}
</div>
</PopoverContent>
</Popover>
)}
</>
)
})()
) : (
<p className="text-xs text-foreground-lighter">—</p>
)}
</div>
</TableCell>
{/* Replies */}
<TableCell className="text-right w-[100px]">
<div className="flex flex-row items-center justify-end gap-2">
{thread.message_count !== null && thread.message_count !== undefined && (
<MessageSquareReply
size={18}
strokeWidth={1.75}
className="text-foreground-muted mt-0.5"
/>
)}
<p className="text-sm text-foreground-lighter whitespace-nowrap">
{thread.message_count !== null && thread.message_count !== undefined
? Math.max(0, thread.message_count - 1)
: '—'}
</p>
</div>
{/* Floating link */}
<Link
href={`/contribute/t/${thread.id}`}
className="absolute inset-0 z-0"
aria-label={`View thread: ${thread.title}`}
tabIndex={1}
/>
</TableCell>
</TableRow>
)
}
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(
<mark key={index} className="bg-brand-200 dark:bg-brand-500 dark:text-foreground! px-0.5">
{text.slice(index, index + searchTerm.length)}
</mark>
)
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
}