@@ -362,26 +361,33 @@ export function ShowcaseModerationList({
) : (
{showcase.status !== 'approved' && (
-
+
)}
{showcase.status !== 'denied' && (
-
+
)}
-
+
)}
@@ -575,7 +580,7 @@ export function ShowcaseModerationList({
)}
-
+
)
})}
diff --git a/src/components/ShowcaseSection.tsx b/src/components/ShowcaseSection.tsx
index a768e7201..0ab0485af 100644
--- a/src/components/ShowcaseSection.tsx
+++ b/src/components/ShowcaseSection.tsx
@@ -8,7 +8,7 @@ import {
} from '~/queries/showcases'
import { voteShowcase } from '~/utils/showcase.functions'
import { ShowcaseCard, ShowcaseCardSkeleton } from './ShowcaseCard'
-import { buttonStyles } from './Button'
+import { Button } from '~/ui'
import { ArrowRight, Plus } from 'lucide-react'
import { useCurrentUser } from '~/hooks/useCurrentUser'
import { useLoginModal } from '~/contexts/LoginModalContext'
@@ -232,13 +232,11 @@ export function ShowcaseSection({
{showViewAll && (
-
- View all projects
-
+
+
)}
diff --git a/src/components/ShowcaseSubmitForm.tsx b/src/components/ShowcaseSubmitForm.tsx
index 6ad3a6c2f..e98b20faa 100644
--- a/src/components/ShowcaseSubmitForm.tsx
+++ b/src/components/ShowcaseSubmitForm.tsx
@@ -1,4 +1,3 @@
-import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import { submitShowcase, updateShowcase } from '~/utils/showcase.functions'
@@ -14,8 +13,9 @@ import {
} from '~/utils/showcase.client'
import { useToast } from './ToastProvider'
import { Check, AlertCircle } from 'lucide-react'
-import { Button } from './Button'
+import { Button, FormInput } from '~/ui'
import { ImageUpload } from './ImageUpload'
+import { FormEvent, useMemo, useState } from 'react'
// Filter to only show libraries with proper configuration
const selectableLibraries = libraries.filter(
@@ -32,29 +32,27 @@ export function ShowcaseSubmitForm({ showcase }: ShowcaseSubmitFormProps) {
const { notify } = useToast()
const isEditMode = !!showcase
- const [name, setName] = React.useState(showcase?.name ?? '')
- const [tagline, setTagline] = React.useState(showcase?.tagline ?? '')
- const [description, setDescription] = React.useState(
- showcase?.description ?? '',
- )
- const [url, setUrl] = React.useState(showcase?.url ?? '')
- const [logoUrl, setLogoUrl] = React.useState
(
+ const [name, setName] = useState(showcase?.name ?? '')
+ const [tagline, setTagline] = useState(showcase?.tagline ?? '')
+ const [description, setDescription] = useState(showcase?.description ?? '')
+ const [url, setUrl] = useState(showcase?.url ?? '')
+ const [logoUrl, setLogoUrl] = useState(
showcase?.logoUrl ?? undefined,
)
- const [screenshotUrl, setScreenshotUrl] = React.useState(
+ const [screenshotUrl, setScreenshotUrl] = useState(
showcase?.screenshotUrl ?? undefined,
)
- const [selectedLibraries, setSelectedLibraries] = React.useState(
+ const [selectedLibraries, setSelectedLibraries] = useState(
showcase?.libraries ?? [],
)
- const [selectedUseCases, setSelectedUseCases] = React.useState<
- ShowcaseUseCase[]
- >(showcase?.useCases ?? [])
- const [isOpenSource, setIsOpenSource] = React.useState(!!showcase?.sourceUrl)
- const [sourceUrl, setSourceUrl] = React.useState(showcase?.sourceUrl ?? '')
+ const [selectedUseCases, setSelectedUseCases] = useState(
+ showcase?.useCases ?? [],
+ )
+ const [isOpenSource, setIsOpenSource] = useState(!!showcase?.sourceUrl)
+ const [sourceUrl, setSourceUrl] = useState(showcase?.sourceUrl ?? '')
// Get auto-included libraries based on selection
- const autoIncluded = React.useMemo(
+ const autoIncluded = useMemo(
() => getAutoIncludedLibraries(selectedLibraries),
[selectedLibraries],
)
@@ -121,7 +119,7 @@ export function ShowcaseSubmitForm({ showcase }: ShowcaseSubmitFormProps) {
)
}
- const handleSubmit = (e: React.FormEvent) => {
+ const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (selectedLibraries.length === 0) {
@@ -207,14 +205,14 @@ export function ShowcaseSubmitForm({ showcase }: ShowcaseSubmitFormProps) {
>
Project Name *
- setName(e.target.value)}
required
maxLength={255}
- className="mt-1 block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
+ className="mt-1 px-4 py-3"
placeholder="My Awesome App"
/>
@@ -227,13 +225,13 @@ export function ShowcaseSubmitForm({ showcase }: ShowcaseSubmitFormProps) {
>
Project URL *
- setUrl(e.target.value)}
required
- className="mt-1 block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
+ className="mt-1 px-4 py-3"
placeholder="https://your-project.com"
/>
@@ -246,14 +244,14 @@ export function ShowcaseSubmitForm({ showcase }: ShowcaseSubmitFormProps) {
>
Tagline *
- setTagline(e.target.value)}
required
maxLength={500}
- className="mt-1 block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
+ className="mt-1 px-4 py-3"
placeholder="A brief description of your project"
/>
@@ -323,13 +321,13 @@ export function ShowcaseSubmitForm({ showcase }: ShowcaseSubmitFormProps) {
>
Source Code URL *
- setSourceUrl(e.target.value)}
required
- className="mt-1 block w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
+ className="mt-1 px-4 py-3"
placeholder="https://github.com/username/repo"
/>
diff --git a/src/components/SimpleMarkdown.tsx b/src/components/SimpleMarkdown.tsx
index 3a3e019ac..d30dc53ea 100644
--- a/src/components/SimpleMarkdown.tsx
+++ b/src/components/SimpleMarkdown.tsx
@@ -8,7 +8,7 @@ import parse, {
HTMLReactParserOptions,
} from 'html-react-parser'
import { renderMarkdown } from '~/utils/markdown'
-import { getNetlifyImageUrl } from '~/utils/netlifyImage'
+import { InlineCode, MarkdownImg } from '~/ui'
/**
* Lightweight markdown renderer for simple content like excerpts.
@@ -18,16 +18,7 @@ import { getNetlifyImageUrl } from '~/utils/netlifyImage'
const markdownComponents: Record> = {
a: MarkdownLink,
- code: function Code({ className, ...rest }: HTMLProps) {
- return (
-
- )
- },
+ code: InlineCode,
pre: function Pre({ children, ...rest }: HTMLProps) {
return (
> = {
)
},
- img: ({
- alt,
- src,
- className,
- children: _,
- ...props
- }: HTMLProps) => (
-
- ),
+ img: MarkdownImg,
}
const options: HTMLReactParserOptions = {
diff --git a/src/components/SponsorsSection.tsx b/src/components/SponsorsSection.tsx
index bc681b545..837466dce 100644
--- a/src/components/SponsorsSection.tsx
+++ b/src/components/SponsorsSection.tsx
@@ -2,7 +2,7 @@ import * as React from 'react'
import { Await } from '@tanstack/react-router'
import { Spinner } from '~/components/Spinner'
import SponsorPack from '~/components/SponsorPack'
-import { Button } from '~/components/Button'
+import { Button } from '~/ui'
import { twMerge } from 'tailwind-merge'
type SponsorsSectionProps = {
diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx
index 2b3bad032..a0e894a08 100644
--- a/src/components/ThemeToggle.tsx
+++ b/src/components/ThemeToggle.tsx
@@ -1,7 +1,7 @@
import * as React from 'react'
import { useTheme } from './ThemeProvider'
import { Moon, Sun, SunMoon } from 'lucide-react'
-import { Button } from './Button'
+import { Button } from '~/ui'
export function ThemeToggle() {
const { themeMode, toggleMode } = useTheme()
@@ -16,7 +16,7 @@ export function ThemeToggle() {
themeMode === 'auto' ? 'Auto' : themeMode === 'light' ? 'Light' : 'Dark'
return (
-
-
+
Cancel
-
-
+
+
{saving ? 'Saving...' : 'Save Entry'}
-
+
@@ -217,11 +209,10 @@ export function FeedEntryEditor({
- setTitle(e.target.value)}
- className={inputClass}
placeholder="Enter a descriptive title"
/>
@@ -255,7 +246,7 @@ export function FeedEntryEditor({
value={excerpt}
onChange={(e) => setExcerpt(e.target.value)}
rows={2}
- className={inputClass}
+ className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
placeholder="Brief summary for feed previews"
/>
@@ -266,11 +257,10 @@ export function FeedEntryEditor({
Published Date *
- setPublishedAt(e.target.value)}
- className={inputClass}
/>
@@ -366,11 +356,10 @@ export function FeedEntryEditor({
(comma-separated)
- setTags(e.target.value)}
- className={inputClass}
placeholder="e.g., release:major, breaking-change"
/>
diff --git a/src/components/admin/FeedSyncStatus.tsx b/src/components/admin/FeedSyncStatus.tsx
index ec3cd52cf..6d13f3841 100644
--- a/src/components/admin/FeedSyncStatus.tsx
+++ b/src/components/admin/FeedSyncStatus.tsx
@@ -8,6 +8,7 @@ import {
} from '~/utils/admin'
import { Spinner } from '~/components/Spinner'
import { RefreshCw } from 'lucide-react'
+import { Button } from '~/ui'
export function FeedSyncStatus({
onSyncComplete,
@@ -63,10 +64,10 @@ export function FeedSyncStatus({
Sync Status
-
{syncingAll ? (
@@ -74,7 +75,7 @@ export function FeedSyncStatus({
)}
Sync All
-
+
diff --git a/src/components/admin/StatusBadge.tsx b/src/components/admin/StatusBadge.tsx
index 4c527f589..25489a76e 100644
--- a/src/components/admin/StatusBadge.tsx
+++ b/src/components/admin/StatusBadge.tsx
@@ -1,47 +1,38 @@
-import { twMerge } from 'tailwind-merge'
+import { Badge } from '~/ui'
type StatusBadgeProps = {
status: string
className?: string
}
-const statusStyles: Record = {
+const statusVariants: Record<
+ string,
+ 'success' | 'warning' | 'error' | 'info' | 'default'
+> = {
// Approval statuses
- approved:
- 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
- pending:
- 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
- denied: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
+ approved: 'success',
+ pending: 'warning',
+ denied: 'error',
// Active/Inactive
- active:
- 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
- inactive: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
+ active: 'success',
+ inactive: 'default',
// Boolean states
- true: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
- false: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
+ true: 'success',
+ false: 'default',
// OAuth providers
- github: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
- google: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
+ github: 'default',
+ google: 'info',
}
-const defaultStyle =
- 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
-
export function StatusBadge({ status, className }: StatusBadgeProps) {
- const style = statusStyles[status.toLowerCase()] || defaultStyle
+ const variant = statusVariants[status.toLowerCase()] || 'default'
return (
-
+
{status}
-
+
)
}
diff --git a/src/components/landing/ConfigLanding.tsx b/src/components/landing/ConfigLanding.tsx
index 6cdd0d75a..7e2fc16d9 100644
--- a/src/components/landing/ConfigLanding.tsx
+++ b/src/components/landing/ConfigLanding.tsx
@@ -6,7 +6,7 @@ import { configProject } from '~/libraries/config'
import { getLibrary } from '~/libraries'
import { LibraryFeatureHighlights } from '~/components/LibraryFeatureHighlights'
import LandingPageGad from '~/components/LandingPageGad'
-import { Button } from '~/components/Button'
+import { Button } from '~/ui'
import { PartnersSection } from '~/components/PartnersSection'
import { MaintainersSection } from '~/components/MaintainersSection'
import { CheckCircleIcon } from '~/components/icons/CheckCircleIcon'
diff --git a/src/components/landing/McpLanding.tsx b/src/components/landing/McpLanding.tsx
index c9cfb3dd4..801d9fcaa 100644
--- a/src/components/landing/McpLanding.tsx
+++ b/src/components/landing/McpLanding.tsx
@@ -7,7 +7,7 @@ import { mcpProject } from '~/libraries/mcp'
import { getLibrary } from '~/libraries'
import { LibraryFeatureHighlights } from '~/components/LibraryFeatureHighlights'
import LandingPageGad from '~/components/LandingPageGad'
-import { Button } from '~/components/Button'
+import { Button } from '~/ui'
import { MaintainersSection } from '~/components/MaintainersSection'
import { LibraryPageContainer } from '~/components/LibraryPageContainer'
import { Key } from 'lucide-react'
@@ -23,11 +23,7 @@ export default function McpLanding() {
project={mcpProject}
actions={
<>
-
+
Get Started
{canApiKeys && (
diff --git a/src/components/landing/StartLanding.tsx b/src/components/landing/StartLanding.tsx
index c4e0f9d8d..6f3ab38df 100644
--- a/src/components/landing/StartLanding.tsx
+++ b/src/components/landing/StartLanding.tsx
@@ -10,7 +10,7 @@ import LandingPageGad from '~/components/LandingPageGad'
import { PartnersSection } from '~/components/PartnersSection'
import { MaintainersSection } from '~/components/MaintainersSection'
import { LibraryTestimonials } from '~/components/LibraryTestimonials'
-import { Button } from '~/components/Button'
+import { Button } from '~/ui'
import { GithubIcon } from '~/components/icons/GithubIcon'
import { Book, Wallpaper } from 'lucide-react'
import { BrandXIcon } from '~/components/icons/BrandXIcon'
diff --git a/src/components/markdown/CodeBlock.tsx b/src/components/markdown/CodeBlock.tsx
index e73762db7..93473e61f 100644
--- a/src/components/markdown/CodeBlock.tsx
+++ b/src/components/markdown/CodeBlock.tsx
@@ -5,7 +5,7 @@ import { Copy } from 'lucide-react'
import type { Mermaid } from 'mermaid'
import { transformerNotationDiff } from '@shikijs/transformers'
import { createHighlighter, type HighlighterGeneric } from 'shiki'
-import { Button } from '../Button'
+import { Button } from '~/ui'
// Language aliases mapping
const LANG_ALIASES: Record = {
@@ -221,6 +221,8 @@ export function CodeBlock({
{
let copyContent =
diff --git a/src/components/markdown/Markdown.tsx b/src/components/markdown/Markdown.tsx
index 580b4b197..a5fa01be5 100644
--- a/src/components/markdown/Markdown.tsx
+++ b/src/components/markdown/Markdown.tsx
@@ -1,6 +1,6 @@
+import type { HTMLProps } from 'react'
import * as React from 'react'
import { MarkdownLink } from './MarkdownLink'
-import type { HTMLProps } from 'react'
import parse, {
attributesToProps,
@@ -10,10 +10,10 @@ import parse, {
} from 'html-react-parser'
import { renderMarkdown } from '~/utils/markdown'
-import { getNetlifyImageUrl } from '~/utils/netlifyImage'
import { CodeBlock } from './CodeBlock'
import { handleTabsComponent } from './MarkdownTabsHandler'
import { handleFrameworkComponent } from './MarkdownFrameworkHandler'
+import { InlineCode, MarkdownImg } from '~/ui'
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
@@ -67,39 +67,6 @@ const makeHeading =
/>
)
-const InlineCode = React.memo(function InlineCode({
- className,
- ...rest
-}: HTMLProps) {
- return (
-
- )
-})
-
-const MarkdownImage = React.memo(function MarkdownImage({
- alt,
- src,
- className,
- children: _,
- ...props
-}: HTMLProps) {
- return (
-
- )
-})
-
const MarkdownIframe = React.memo(function MarkdownIframe(
props: HTMLProps,
) {
@@ -117,7 +84,7 @@ const markdownComponents: Record = {
h6: makeHeading('h6'),
code: InlineCode,
iframe: MarkdownIframe,
- img: MarkdownImage,
+ img: MarkdownImg,
}
const options: HTMLReactParserOptions = {
@@ -178,13 +145,11 @@ export const Markdown = React.memo(function Markdown({
return { markup: '', headings: [] }
}, [rawContent, htmlMarkup])
- const parsed = React.useMemo(() => {
+ return React.useMemo(() => {
if (!rendered.markup) {
return null
}
return parse(rendered.markup, options)
}, [rendered.markup])
-
- return parsed
})
diff --git a/src/components/markdown/MarkdownContent.tsx b/src/components/markdown/MarkdownContent.tsx
index 7567b046f..86553d4a3 100644
--- a/src/components/markdown/MarkdownContent.tsx
+++ b/src/components/markdown/MarkdownContent.tsx
@@ -5,7 +5,7 @@ import { DocTitle } from '~/components/DocTitle'
import { Markdown } from './Markdown'
import { CopyPageDropdown } from '~/components/CopyPageDropdown'
import { DocFeedbackProvider } from '~/components/DocFeedbackProvider'
-import { Button } from '~/components/Button'
+import { Button } from '~/ui'
type MarkdownContentProps = {
title: string
@@ -93,6 +93,8 @@ export function MarkdownContent({
diff --git a/src/routes/$libraryId/$version.index.tsx b/src/routes/$libraryId/$version.index.tsx
index 3d53c84ec..a3a5a4771 100644
--- a/src/routes/$libraryId/$version.index.tsx
+++ b/src/routes/$libraryId/$version.index.tsx
@@ -10,7 +10,7 @@ import { getLibrary } from '~/libraries'
import type { LibraryId } from '~/libraries'
import { seo } from '~/utils/seo'
import { ossStatsQuery } from '~/queries/stats'
-import { Button } from '~/components/Button'
+import { Button } from '~/ui'
// Lazy-loaded landing components for each library
const landingComponents: Partial<
diff --git a/src/routes/account/index.tsx b/src/routes/account/index.tsx
index e6801858a..bf89d5436 100644
--- a/src/routes/account/index.tsx
+++ b/src/routes/account/index.tsx
@@ -22,7 +22,7 @@ import {
Trash2,
} from 'lucide-react'
import { Card } from '~/components/Card'
-import { Button } from '~/components/Button'
+import { Button } from '~/ui'
import { Avatar } from '~/components/Avatar'
import { AvatarCropModal } from '~/components/AvatarCropModal'
import { useUploadThing } from '~/utils/uploadthing'
diff --git a/src/routes/account/integrations.tsx b/src/routes/account/integrations.tsx
index 904abbfb5..72ccc8953 100644
--- a/src/routes/account/integrations.tsx
+++ b/src/routes/account/integrations.tsx
@@ -1,5 +1,5 @@
import * as React from 'react'
-import { createFileRoute, Link } from '@tanstack/react-router'
+import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
listMcpApiKeys,
@@ -13,8 +13,7 @@ import {
} from '~/utils/oauthClient.functions'
import { useToast } from '~/components/ToastProvider'
import { Card } from '~/components/Card'
-import { Button } from '~/components/Button'
-import { CodeBlock } from '~/components/markdown'
+import { Button, FormInput } from '~/ui'
import {
Key,
Plus,
@@ -225,13 +224,13 @@ function IntegrationsPage() {
>
Name
- setNewKeyName(e.target.value)}
placeholder="e.g., My Claude Desktop"
- className="w-full max-w-sm border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
+ className="max-w-sm text-sm rounded-md"
/>
diff --git a/src/routes/account/submissions.tsx b/src/routes/account/submissions.tsx
index c3adeb291..3f14411bd 100644
--- a/src/routes/account/submissions.tsx
+++ b/src/routes/account/submissions.tsx
@@ -16,7 +16,7 @@ import {
Pencil,
} from 'lucide-react'
import type { Showcase } from '~/db/types'
-import { Button, buttonStyles } from '~/components/Button'
+import { Badge, Button } from '~/ui'
export const Route = createFileRoute('/account/submissions')({
loader: async ({ context: { queryClient } }) => {
@@ -79,24 +79,24 @@ function AccountSubmissionsPage() {
switch (status) {
case 'pending':
return (
-
+
Pending Review
-
+
)
case 'approved':
return (
-
+
Approved
-
+
)
case 'denied':
return (
-
+
Denied
-
+
)
}
}
@@ -193,13 +193,11 @@ function AccountSubmissionsPage() {
Visit
-
-
- Edit
+
+
+
+ Edit
+
handleDelete(showcase.id)}>
diff --git a/src/routes/admin/banners.$id.tsx b/src/routes/admin/banners.$id.tsx
index cc4ddda11..2d1d66943 100644
--- a/src/routes/admin/banners.$id.tsx
+++ b/src/routes/admin/banners.$id.tsx
@@ -5,6 +5,7 @@ import { getBanner, type BannerWithMeta } from '~/utils/banner.functions'
import { useCapabilities } from '~/hooks/useCapabilities'
import { useCurrentUserQuery } from '~/hooks/useCurrentUser'
import { hasCapability } from '~/db/types'
+import { Button } from '~/ui'
import * as v from 'valibot'
export const Route = createFileRoute('/admin/banners/$id')({
@@ -62,12 +63,12 @@ function BannerEditorPage() {
Banner Not Found
- navigate({ to: '/admin/banners' })}
- className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg"
>
Back to Banners
-
+
)
diff --git a/src/routes/admin/banners.index.tsx b/src/routes/admin/banners.index.tsx
index aa94f0c29..23265fbe4 100644
--- a/src/routes/admin/banners.index.tsx
+++ b/src/routes/admin/banners.index.tsx
@@ -21,6 +21,7 @@ import {
ExternalLink,
Flag,
} from 'lucide-react'
+import { Button } from '~/ui'
import * as v from 'valibot'
import { formatDistanceToNow } from '~/utils/dates'
import {
@@ -125,13 +126,11 @@ function BannersAdminPage() {
title="Banner Management"
isLoading={bannersQuery.isLoading}
actions={
-
-
- Create Banner
+
+
+
+ Create Banner
+
}
/>
diff --git a/src/routes/admin/feed.$id.tsx b/src/routes/admin/feed.$id.tsx
index 86fdef6b8..6349ed600 100644
--- a/src/routes/admin/feed.$id.tsx
+++ b/src/routes/admin/feed.$id.tsx
@@ -6,6 +6,7 @@ import { useCapabilities } from '~/hooks/useCapabilities'
import { useCurrentUserQuery } from '~/hooks/useCurrentUser'
import { hasCapability } from '~/db/types'
import { getFeedEntryQueryOptions } from '~/queries/feed'
+import { Button } from '~/ui'
import * as v from 'valibot'
export const Route = createFileRoute('/admin/feed/$id')({
component: FeedEditorPage,
@@ -60,12 +61,12 @@ function FeedEditorPage() {
Entry Not Found
- navigate({ to: '/admin/feed' })}
- className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg"
>
Back to Feed Admin
-
+
)
diff --git a/src/routes/admin/feed.index.tsx b/src/routes/admin/feed.index.tsx
index a3ee61ffc..37b4639f9 100644
--- a/src/routes/admin/feed.index.tsx
+++ b/src/routes/admin/feed.index.tsx
@@ -6,6 +6,7 @@ import {
} from '~/utils/mutations'
import * as v from 'valibot'
import { Plus } from 'lucide-react'
+import { Button } from '~/ui'
import { FeedEntry } from '~/components/FeedEntry'
import { FeedSyncStatus } from '~/components/admin/FeedSyncStatus'
import { FeedPage as FeedPageComponent } from '~/components/FeedPage'
@@ -121,13 +122,11 @@ function FeedAdminPage() {
Feed Admin
-
-
- Create Entry
+
+
+
+ Create Entry
+
diff --git a/src/routes/admin/feedback_.$id.tsx b/src/routes/admin/feedback_.$id.tsx
index d6a0eac40..b42cde6e5 100644
--- a/src/routes/admin/feedback_.$id.tsx
+++ b/src/routes/admin/feedback_.$id.tsx
@@ -13,12 +13,11 @@ import {
FileText,
Check,
X,
- ExternalLink,
Clock,
AlertTriangle,
} from 'lucide-react'
import { Card } from '~/components/Card'
-import { Button } from '~/components/Button'
+import { Badge, Button } from '~/ui'
import { format } from '~/utils/dates'
export const Route = createFileRoute('/admin/feedback_/$id')({
@@ -92,19 +91,16 @@ function FeedbackDetailPage() {
const { feedback, user } = data
const library = libraries.find((l) => l.id === feedback.libraryId)
- const statusColors = {
- pending:
- 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
- approved:
- 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
- denied: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
- }
+ const statusVariant = {
+ pending: 'warning',
+ approved: 'success',
+ denied: 'error',
+ } as const
- const typeColors = {
- improvement:
- 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
- note: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
- }
+ const typeVariant = {
+ improvement: 'info',
+ note: 'purple',
+ } as const
return (
@@ -128,16 +124,20 @@ function FeedbackDetailPage() {
Feedback
-
{feedback.status}
-
-
+
{feedback.type}
-
+
{feedback.isDetached && (
diff --git a/src/routes/admin/github-stats.tsx b/src/routes/admin/github-stats.tsx
index 32ce9d0e4..cead57142 100644
--- a/src/routes/admin/github-stats.tsx
+++ b/src/routes/admin/github-stats.tsx
@@ -25,6 +25,7 @@ import { formatDistanceToNow } from '~/utils/dates'
import { GithubIcon } from '~/components/icons/GithubIcon'
import { Box, RefreshCw, Star, Users } from 'lucide-react'
import { Card } from '~/components/Card'
+import { Button } from '~/ui'
type GitHubStatsEntry = {
cacheKey: string
@@ -283,17 +284,17 @@ function GitHubStatsAdmin() {
cell: ({ row }) => {
const isRefreshing = refreshingKey === row.original.cacheKey
return (
- {
setRefreshingKey(row.original.cacheKey)
refreshMutation.mutate(row.original.cacheKey)
}}
disabled={isRefreshing || refreshAllMutation.isPending}
- className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
Refresh
-
+
)
},
},
@@ -323,16 +324,16 @@ function GitHubStatsAdmin() {
{cacheEntries && cacheEntries.length > 0 && (
-
refreshAllMutation.mutate()}
disabled={refreshAllMutation.isPending || refreshingKey !== null}
- className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Refresh All
-
+
)}
diff --git a/src/routes/admin/index.tsx b/src/routes/admin/index.tsx
index 89a9737e1..dc2c4a6a4 100644
--- a/src/routes/admin/index.tsx
+++ b/src/routes/admin/index.tsx
@@ -26,6 +26,7 @@ import {
Users,
} from 'lucide-react'
import { Card } from '~/components/Card'
+import { Badge, Button } from '~/ui'
import * as v from 'valibot'
import { TimeSeriesChart } from '~/components/charts/TimeSeriesChart'
import { ChartControls } from '~/components/charts/ChartControls'
@@ -73,11 +74,8 @@ function AdminPage() {
You don't have permission to access the admin area.
-
- Back to Home
+
+
Back to Home
@@ -482,11 +480,8 @@ function UsersTab({
View and manage individual user accounts, roles, and capabilities.
-
- Manage Users
+
+ Manage Users
@@ -728,15 +723,9 @@ function ActivityTab({
key={provider}
className="flex items-center justify-between"
>
-
+
{provider}
-
+
{count.toLocaleString()}
diff --git a/src/routes/admin/logins.tsx b/src/routes/admin/logins.tsx
index 272822c30..bc395d90e 100644
--- a/src/routes/admin/logins.tsx
+++ b/src/routes/admin/logins.tsx
@@ -22,6 +22,7 @@ import {
import * as v from 'valibot'
import { listLoginHistory } from '~/utils/audit.functions'
import { LogIn } from 'lucide-react'
+import { Badge } from '~/ui'
import {
AdminAccessDenied,
AdminLoading,
@@ -120,8 +121,6 @@ function LoginsPage() {
placeholderData: keepPreviousData,
})
- const hasActiveFilters = userIdFilter !== '' || !!providerFilter
-
const handleClearFilters = () => {
navigate({
resetScroll: false,
@@ -205,15 +204,9 @@ function LoginsPage() {
cell: ({ getValue }) => {
const provider = getValue() as string
return (
-
+
{provider}
-
+
)
},
},
@@ -223,15 +216,9 @@ function LoginsPage() {
cell: ({ getValue }) => {
const isNew = getValue() as boolean
return (
-
+
{isNew ? 'Signup' : 'Login'}
-
+
)
},
},
diff --git a/src/routes/admin/npm-stats.tsx b/src/routes/admin/npm-stats.tsx
index 5b3418909..2f417b2d0 100644
--- a/src/routes/admin/npm-stats.tsx
+++ b/src/routes/admin/npm-stats.tsx
@@ -27,6 +27,7 @@ import { formatDistanceToNow } from '~/utils/dates'
import { Download, RefreshCw } from 'lucide-react'
import { NpmIcon } from '~/components/icons/NpmIcon'
import { Card } from '~/components/Card'
+import { Button } from '~/ui'
type NpmPackage = {
id: string
@@ -249,18 +250,18 @@ function NpmStatsAdmin() {
id: 'actions',
header: 'Actions',
cell: ({ row }) => (
-
refreshPackageMutation.mutate(row.original.packageName)
}
disabled={refreshPackageMutation.isPending}
- className="flex items-center gap-2 px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
Refresh
-
+
),
},
],
@@ -304,13 +305,13 @@ function NpmStatsAdmin() {
rebuild all caches.
- {
console.log('[Admin UI] Refresh button clicked')
refreshAllMutation.mutate('tanstack')
}}
disabled={refreshAllMutation.isPending}
- className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
title="Complete refresh: discover packages, fetch fresh stats with growth rates, and rebuild all caches"
>
+
{/* Org Stats Section - Top of Page */}
diff --git a/src/routes/admin/roles.$roleId.tsx b/src/routes/admin/roles.$roleId.tsx
index f555c6c32..a764d38a6 100644
--- a/src/routes/admin/roles.$roleId.tsx
+++ b/src/routes/admin/roles.$roleId.tsx
@@ -13,6 +13,7 @@ import {
import { ArrowLeft, Lock, Trash, User, Users } from 'lucide-react'
import { requireCapability } from '~/utils/auth.server'
import { hasCapability } from '~/db/types'
+import { Badge, Button } from '~/ui'
export const Route = createFileRoute('/admin/roles/$roleId')({
beforeLoad: async () => {
@@ -169,12 +170,9 @@ function RoleDetailPage() {
return (
{(user.capabilities || []).map((capability: string) => (
-
+
{capability}
-
+
))}
{(!user.capabilities || user.capabilities.length === 0) && (
@@ -288,12 +286,9 @@ function RoleDetailPage() {
{role.capabilities.map((capability) => (
-
+
{capability}
-
+
))}
@@ -303,7 +298,7 @@ function RoleDetailPage() {
{selectedUserIds.size} user(s) selected
- {
if (
window.confirm(
@@ -313,10 +308,10 @@ function RoleDetailPage() {
handleRemoveUsers()
}
}}
- className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
+ color="red"
>
Remove from Role
-
+
)}
@@ -330,13 +325,13 @@ function RoleDetailPage() {
Remove {confirmRemove.name} from role "{role?.name}"?
- setConfirmRemove(null)}
- className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600"
+ variant="secondary"
>
Cancel
-
-
+ {
try {
await removeUsersFromRole.mutateAsync({
@@ -358,10 +353,10 @@ function RoleDetailPage() {
)
}
}}
- className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
+ color="red"
>
Remove
-
+
diff --git a/src/routes/admin/roles.index.tsx b/src/routes/admin/roles.index.tsx
index fb78cbd6e..8ce88aac3 100644
--- a/src/routes/admin/roles.index.tsx
+++ b/src/routes/admin/roles.index.tsx
@@ -41,6 +41,7 @@ import { useAdminGuard } from '~/hooks/useAdminGuard'
import { requireCapability } from '~/utils/auth.server'
import { useToggleArray } from '~/hooks/useToggleArray'
import { useDeleteWithConfirmation } from '~/hooks/useDeleteWithConfirmation'
+import { Badge, Button, FormInput } from '~/ui'
// Role type for table - matches the shape returned by listRoles
interface Role {
@@ -205,22 +206,6 @@ function RolesPage() {
itemLabel: 'role',
})
- const handleCapabilityFilterToggle = useCallback(
- (capability: string) => {
- const newFilters = capabilityFilters.includes(capability)
- ? capabilityFilters.filter((c: string) => c !== capability)
- : [...capabilityFilters, capability]
- navigate({
- resetScroll: false,
- search: (prev: { name?: string; cap?: string | string[] }) => ({
- ...prev,
- cap: newFilters.length > 0 ? newFilters : undefined,
- }),
- })
- },
- [capabilityFilters, navigate],
- )
-
const handleSendTestEmail = useCallback(async () => {
setTestEmailStatus({ loading: true })
try {
@@ -312,12 +297,9 @@ function RolesPage() {
) : (
{(role.capabilities || []).map((capability) => (
-
+
{capability}
-
+
))}
{(!role.capabilities || role.capabilities.length === 0) && (
@@ -440,13 +422,10 @@ function RolesPage() {
isLoading={rolesQuery.isFetching}
actions={
!isCreating && (
-
+
Create Role
-
+
)
}
/>
@@ -476,14 +455,15 @@ function RolesPage() {
))}
-
{testEmailStatus.loading ? 'Sending...' : 'Test Email'}
-
+
}
/>
@@ -502,11 +482,11 @@ function RolesPage() {
>
Name
- setEditingName(e.target.value)}
- className="w-full px-3 py-2 border rounded-md bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white"
+ className="rounded-md"
placeholder="Role name"
/>
@@ -517,11 +497,11 @@ function RolesPage() {
>
Description
- setEditingDescription(e.target.value)}
- className="w-full px-3 py-2 border rounded-md bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white"
+ className="rounded-md"
placeholder="Role description"
/>
@@ -549,20 +529,14 @@ function RolesPage() {
-
+
Save
-
-
+
+
Cancel
-
+
diff --git a/src/routes/admin/showcases_.$id.tsx b/src/routes/admin/showcases_.$id.tsx
index da5a0d6f1..a63ebfded 100644
--- a/src/routes/admin/showcases_.$id.tsx
+++ b/src/routes/admin/showcases_.$id.tsx
@@ -28,7 +28,7 @@ import {
RotateCcw,
} from 'lucide-react'
import { Card } from '~/components/Card'
-import { Button } from '~/components/Button'
+import { Badge, Button, FormInput } from '~/ui'
import { ImageUpload } from '~/components/ImageUpload'
import { format } from '~/utils/dates'
import {
@@ -230,16 +230,12 @@ function ShowcaseDetailPage() {
.map((libId: string) => libraries.find((l) => l.id === libId))
.filter(Boolean)
- const statusColors = {
- pending:
- 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
- approved:
- 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
- denied: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
- }
+ const statusVariant = {
+ pending: 'warning',
+ approved: 'success',
+ denied: 'error',
+ } as const
- const inputClass =
- 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent'
const labelClass =
'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'
@@ -330,7 +326,7 @@ function ShowcaseDetailPage() {
{isEditing ? (
-
@@ -338,10 +334,10 @@ function ShowcaseDetailPage() {
prev ? { ...prev, name: e.target.value } : null,
)
}
- className={`${inputClass} text-xl font-bold`}
+ className="text-xl font-bold"
placeholder="Showcase name"
/>
-
@@ -349,7 +345,6 @@ function ShowcaseDetailPage() {
prev ? { ...prev, tagline: e.target.value } : null,
)
}
- className={inputClass}
placeholder="Tagline"
/>
@@ -359,15 +354,17 @@ function ShowcaseDetailPage() {
{showcase.name}
-
{showcase.status}
-
+
{showcase.isFeatured && (
-
- Featured
-
+
Featured
)}
@@ -417,7 +414,7 @@ function ShowcaseDetailPage() {
-
@@ -446,7 +442,7 @@ function ShowcaseDetailPage() {
-
@@ -476,7 +471,7 @@ function ShowcaseDetailPage() {
prev ? { ...prev, description: e.target.value } : null,
)
}
- className={`${inputClass} min-h-[100px]`}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent min-h-[100px]"
placeholder="Describe the showcase..."
/>
@@ -484,7 +479,7 @@ function ShowcaseDetailPage() {
-
@@ -511,7 +505,7 @@ function ShowcaseDetailPage() {
-
@@ -788,7 +781,7 @@ function ShowcaseDetailPage() {
: null,
)
}
- className={inputClass}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{SHOWCASE_STATUSES.map((status) => (
-
- Learn More About Workshops
+
+ Learn More About Workshops
diff --git a/src/routes/partners.tsx b/src/routes/partners.tsx
index 146ae0e95..f62c65b5b 100644
--- a/src/routes/partners.tsx
+++ b/src/routes/partners.tsx
@@ -7,6 +7,7 @@ import { Library } from '~/libraries'
import { useState } from 'react'
import * as React from 'react'
import { ListFilter, X } from 'lucide-react'
+import { Button } from '~/ui'
import { startProject } from '~/libraries/start'
import { routerProject } from '~/libraries/router'
import { queryProject } from '~/libraries/query'
@@ -482,12 +483,12 @@ function RouteComp() {
No partners found for the selected filters.
- navigate({ search: {}, replace: true })}
- className="inline-block px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm"
>
View All Partners
-
+
) : (
No partners to display.
diff --git a/src/ui/Badge.tsx b/src/ui/Badge.tsx
new file mode 100644
index 000000000..f97ba812f
--- /dev/null
+++ b/src/ui/Badge.tsx
@@ -0,0 +1,50 @@
+import { twMerge } from 'tailwind-merge'
+
+type BadgeVariant =
+ | 'default'
+ | 'success'
+ | 'warning'
+ | 'error'
+ | 'info'
+ | 'purple'
+ | 'teal'
+ | 'orange'
+
+type BadgeProps = {
+ children: React.ReactNode
+ variant?: BadgeVariant
+ className?: string
+}
+
+const variantStyles: Record = {
+ default: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
+ success:
+ 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
+ warning:
+ 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
+ error: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
+ info: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
+ purple:
+ 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
+ teal: 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-300',
+ orange:
+ 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300',
+}
+
+export function Badge({
+ children,
+ variant = 'default',
+ className,
+}: BadgeProps) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/ui/Button.tsx b/src/ui/Button.tsx
new file mode 100644
index 000000000..968ca934a
--- /dev/null
+++ b/src/ui/Button.tsx
@@ -0,0 +1,141 @@
+import * as React from 'react'
+import { twMerge } from 'tailwind-merge'
+
+type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'icon'
+
+type ButtonColor =
+ | 'blue'
+ | 'green'
+ | 'red'
+ | 'orange'
+ | 'purple'
+ | 'gray'
+ | 'emerald'
+ | 'cyan'
+ | 'yellow'
+
+type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon-sm' | 'icon-md'
+
+type ButtonRounded = 'none' | 'md' | 'lg' | 'full'
+
+type ButtonOwnProps = {
+ as?: TElement
+ children: React.ReactNode
+ variant?: ButtonVariant
+ color?: ButtonColor
+ size?: ButtonSize
+ rounded?: ButtonRounded
+ className?: string
+}
+
+type ButtonProps =
+ ButtonOwnProps &
+ Omit<
+ React.ComponentPropsWithoutRef,
+ keyof ButtonOwnProps
+ >
+
+type ButtonComponent = (
+ props: ButtonProps,
+) => React.ReactNode
+
+const primaryColorStyles: Record = {
+ blue: 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700',
+ green: 'bg-green-600 text-white border-green-600 hover:bg-green-700',
+ red: 'bg-red-600 text-white border-red-600 hover:bg-red-700',
+ orange: 'bg-orange-600 text-white border-orange-600 hover:bg-orange-700',
+ purple: 'bg-purple-600 text-white border-purple-600 hover:bg-purple-700',
+ gray: 'bg-gray-600 text-white border-gray-600 hover:bg-gray-700',
+ emerald: 'bg-emerald-500 text-white border-emerald-500 hover:bg-emerald-600',
+ cyan: 'bg-cyan-500 text-white border-cyan-500 hover:bg-cyan-600',
+ yellow: 'bg-yellow-400 text-black border-yellow-400 hover:bg-yellow-500',
+}
+
+const iconColorStyles: Record = {
+ blue: 'text-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900/30',
+ green: 'text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30',
+ red: 'text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30',
+ orange: 'text-orange-600 hover:bg-orange-100 dark:hover:bg-orange-900/30',
+ purple: 'text-purple-600 hover:bg-purple-100 dark:hover:bg-purple-900/30',
+ gray: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700',
+ emerald: 'text-emerald-600 hover:bg-emerald-100 dark:hover:bg-emerald-900/30',
+ cyan: 'text-cyan-600 hover:bg-cyan-100 dark:hover:bg-cyan-900/30',
+ yellow: 'text-yellow-600 hover:bg-yellow-100 dark:hover:bg-yellow-900/30',
+}
+
+const variantStyles: Record = {
+ primary: 'border font-medium',
+ secondary:
+ 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 border-transparent font-medium',
+ ghost:
+ 'border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 font-medium',
+ icon: 'border-transparent',
+}
+
+const sizeStyles: Record = {
+ xs: 'px-2 py-1.5 text-xs',
+ sm: 'px-3 py-1 text-sm',
+ md: 'px-4 py-2 text-sm',
+ lg: 'px-6 py-3 text-base',
+ 'icon-sm': 'p-1.5',
+ 'icon-md': 'p-2',
+}
+
+const roundedStyles: Record = {
+ none: 'rounded-none',
+ md: 'rounded-md',
+ lg: 'rounded-lg',
+ full: 'rounded-full',
+}
+
+const baseStyles =
+ 'inline-flex items-center justify-center gap-2 cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
+
+function getDefaultSize(variant: ButtonVariant): ButtonSize {
+ if (variant === 'icon') return 'icon-md'
+ return 'md'
+}
+
+function getDefaultRounded(size: ButtonSize): ButtonRounded {
+ if (size === 'xs' || size === 'sm') return 'md'
+ if (size === 'icon-sm' || size === 'icon-md') return 'lg'
+ return 'lg'
+}
+
+export const Button: ButtonComponent = ({
+ as,
+ children,
+ variant = 'primary',
+ color = 'blue',
+ size,
+ rounded,
+ className,
+ ...props
+}) => {
+ const Component = as || 'button'
+ const resolvedSize = size ?? getDefaultSize(variant)
+ const resolvedRounded = rounded ?? getDefaultRounded(resolvedSize)
+
+ const colorStyles =
+ variant === 'primary'
+ ? primaryColorStyles[color]
+ : variant === 'icon'
+ ? iconColorStyles[color]
+ : ''
+
+ return React.createElement(
+ Component,
+ {
+ className: twMerge(
+ baseStyles,
+ variantStyles[variant],
+ sizeStyles[resolvedSize],
+ roundedStyles[resolvedRounded],
+ colorStyles,
+ className,
+ ),
+ ...props,
+ },
+ children,
+ )
+}
diff --git a/src/ui/FormInput.tsx b/src/ui/FormInput.tsx
new file mode 100644
index 000000000..7996cdab8
--- /dev/null
+++ b/src/ui/FormInput.tsx
@@ -0,0 +1,29 @@
+import * as React from 'react'
+import { twMerge } from 'tailwind-merge'
+
+type FormInputProps = React.InputHTMLAttributes & {
+ /** Ring color on focus. Defaults to blue. */
+ focusRing?: 'blue' | 'orange' | 'purple'
+}
+
+const ringStyles = {
+ blue: 'focus:ring-blue-500',
+ orange: 'focus:ring-orange-500',
+ purple: 'focus:ring-purple-500',
+}
+
+export const FormInput = React.forwardRef(
+ function FormInput({ className, focusRing = 'blue', ...props }, ref) {
+ return (
+
+ )
+ },
+)
diff --git a/src/ui/InlineCode.tsx b/src/ui/InlineCode.tsx
new file mode 100644
index 000000000..9e0dfa9a6
--- /dev/null
+++ b/src/ui/InlineCode.tsx
@@ -0,0 +1,16 @@
+import * as React from 'react'
+import type { HTMLProps } from 'react'
+
+export const InlineCode = React.memo(function InlineCode({
+ className,
+ ...rest
+}: HTMLProps) {
+ return (
+
+ )
+})
diff --git a/src/ui/LogoQueryGG.tsx b/src/ui/LogoQueryGG.tsx
new file mode 100644
index 000000000..af86c606e
--- /dev/null
+++ b/src/ui/LogoQueryGG.tsx
@@ -0,0 +1,389 @@
+type LogoQueryGGProps = Omit, 'size'> & {
+ /** Size variant. 'small' uses compact viewBox. Defaults to 'default'. */
+ size?: 'default' | 'small'
+}
+
+export function LogoQueryGG({ size = 'default', ...props }: LogoQueryGGProps) {
+ if (size === 'small') {
+ return (
+
+
+
+ )
+ }
+
+ // Default (large) size - keeping original SVG content
+ return (
+
+
+
+ )
+}
diff --git a/src/ui/MarkdownImg.tsx b/src/ui/MarkdownImg.tsx
new file mode 100644
index 000000000..141dac6bf
--- /dev/null
+++ b/src/ui/MarkdownImg.tsx
@@ -0,0 +1,22 @@
+import * as React from 'react'
+import type { HTMLProps } from 'react'
+import { getNetlifyImageUrl } from '~/utils/netlifyImage'
+
+export const MarkdownImg = React.memo(function MarkdownImg({
+ alt,
+ src,
+ className,
+ children: _,
+ ...props
+}: HTMLProps) {
+ return (
+
+ )
+})
diff --git a/src/ui/index.ts b/src/ui/index.ts
new file mode 100644
index 000000000..f341db8fc
--- /dev/null
+++ b/src/ui/index.ts
@@ -0,0 +1,6 @@
+export { Badge } from './Badge'
+export { Button } from './Button'
+export { FormInput } from './FormInput'
+export { InlineCode } from './InlineCode'
+export { MarkdownImg } from './MarkdownImg'
+export { LogoQueryGG } from './LogoQueryGG'