diff --git a/src/components/AvatarCropModal.tsx b/src/components/AvatarCropModal.tsx index 5a7edf3f6..b40d7c51f 100644 --- a/src/components/AvatarCropModal.tsx +++ b/src/components/AvatarCropModal.tsx @@ -3,6 +3,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog' import Cropper from 'react-easy-crop' import type { Area, Point } from 'react-easy-crop' import { X } from 'lucide-react' +import { Button } from '~/ui' interface AvatarCropModalProps { open: boolean @@ -153,21 +154,20 @@ export function AvatarCropModal({
- - +
diff --git a/src/components/BottomCTA.tsx b/src/components/BottomCTA.tsx index a7b6a8841..7bd49ffed 100644 --- a/src/components/BottomCTA.tsx +++ b/src/components/BottomCTA.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import type { LinkProps } from '@tanstack/react-router' import { Link } from '@tanstack/react-router' -import { Button } from './Button' +import { Button } from '~/ui' type BottomCTAProps = { linkProps: Omit diff --git a/src/components/Button.tsx b/src/components/Button.tsx deleted file mode 100644 index f3e22d9dc..000000000 --- a/src/components/Button.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import * as React from 'react' -import { twMerge } from 'tailwind-merge' - -export const buttonStyles = [ - 'flex items-center gap-1.5 rounded-md px-2 py-1.5', - 'border border-gray-200 dark:border-gray-700', - 'hover:bg-gray-100 dark:hover:bg-gray-800', - 'cursor-pointer transition-colors duration-200 text-xs font-medium', -].join(' ') - -type ButtonOwnProps = { - as?: TElement - children: React.ReactNode - className?: string -} - -type ButtonProps = - ButtonOwnProps & - Omit< - React.ComponentPropsWithoutRef, - keyof ButtonOwnProps - > - -type ButtonComponent = ( - props: ButtonProps, -) => React.ReactNode - -export const Button: ButtonComponent = ({ - as, - children, - className, - ...props -}) => { - const Component = as || 'button' - return React.createElement( - Component, - { className: twMerge(buttonStyles, className), ...props }, - children, - ) -} diff --git a/src/components/CookieConsent.tsx b/src/components/CookieConsent.tsx index 0acd24bdb..9feb11f3f 100644 --- a/src/components/CookieConsent.tsx +++ b/src/components/CookieConsent.tsx @@ -1,6 +1,6 @@ import { Link } from '@tanstack/react-router' import { useEffect, useState } from 'react' -import { Button } from './Button' +import { Button } from '~/ui' declare global { interface Window { @@ -184,22 +184,13 @@ export default function CookieConsent() { for details.
- - -
@@ -264,10 +255,7 @@ export default function CookieConsent() {
-
diff --git a/src/components/CopyMarkdownButton.tsx b/src/components/CopyMarkdownButton.tsx index 4e174766e..4754b8e87 100644 --- a/src/components/CopyMarkdownButton.tsx +++ b/src/components/CopyMarkdownButton.tsx @@ -4,7 +4,7 @@ import { useState, useTransition } from 'react' import { type MouseEventHandler, useEffect, useRef } from 'react' import { useToast } from '~/components/ToastProvider' import { Check, Copy } from 'lucide-react' -import { Button } from './Button' +import { Button } from '~/ui' export function useCopyButton( onCopy: () => void | Promise, diff --git a/src/components/CopyPageDropdown.tsx b/src/components/CopyPageDropdown.tsx index 8489ef93e..6a4da6fd2 100644 --- a/src/components/CopyPageDropdown.tsx +++ b/src/components/CopyPageDropdown.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { ChevronDown, Copy, Check } from 'lucide-react' import { useToast } from '~/components/ToastProvider' -import { Button } from './Button' +import { Button } from '~/ui' import { ButtonGroup } from './ButtonGroup' import { Dropdown, @@ -242,7 +242,13 @@ export function CopyPageDropdown({ return ( - - diff --git a/src/components/DefaultCatchBoundary.tsx b/src/components/DefaultCatchBoundary.tsx index 18ed9bd3a..02c2c7600 100644 --- a/src/components/DefaultCatchBoundary.tsx +++ b/src/components/DefaultCatchBoundary.tsx @@ -8,7 +8,7 @@ import { } from '@tanstack/react-router' import * as Sentry from '@sentry/tanstackstart-react' -import { Button } from './Button' +import { Button } from '~/ui' import { useEffect } from 'react' // type DefaultCatchBoundaryType = { @@ -67,26 +67,20 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
{isRoot ? ( - ) : ( - +
)} {isModeratingThis && ( diff --git a/src/components/FeedbackModerationTopBar.tsx b/src/components/FeedbackModerationTopBar.tsx index d50d1f944..e00cba36f 100644 --- a/src/components/FeedbackModerationTopBar.tsx +++ b/src/components/FeedbackModerationTopBar.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { libraries } from '~/libraries' import { DOC_FEEDBACK_STATUSES, type DocFeedbackStatus } from '~/db/types' import { @@ -9,6 +8,7 @@ import { FilterCheckbox, getFilterChipLabel, } from '~/components/FilterComponents' +import { FormInput } from '~/ui' interface FeedbackModerationTopBarProps { filters: { @@ -169,14 +169,14 @@ export function FeedbackModerationTopBar({ > From - onFilterChange({ dateFrom: e.target.value || undefined }) } - className="w-full px-3 py-2 text-sm 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="text-sm" />
@@ -186,14 +186,14 @@ export function FeedbackModerationTopBar({ > To - onFilterChange({ dateTo: e.target.value || undefined }) } - className="w-full px-3 py-2 text-sm 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="text-sm" />
diff --git a/src/components/LazySponsorSection.tsx b/src/components/LazySponsorSection.tsx index 42a080546..2d7915c63 100644 --- a/src/components/LazySponsorSection.tsx +++ b/src/components/LazySponsorSection.tsx @@ -3,7 +3,7 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { ArrowRight } from 'lucide-react' import { useIntersectionObserver } from '~/hooks/useIntersectionObserver' import { getSponsorsForSponsorPack } from '~/server/sponsors' -import { Button } from './Button' +import { Button } from '~/ui' import SponsorPack from './SponsorPack' import PlaceholderSponsorPack from './PlaceholderSponsorPack' diff --git a/src/components/LibraryHero.tsx b/src/components/LibraryHero.tsx index 218b01e89..5c4fd69fd 100644 --- a/src/components/LibraryHero.tsx +++ b/src/components/LibraryHero.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { twMerge } from 'tailwind-merge' import { Link, LinkProps } from '@tanstack/react-router' import type { Library } from '~/libraries' -import { Button } from './Button' +import { Button } from '~/ui' type LibraryHeroProps = { project: Library diff --git a/src/components/MaintainersSection.tsx b/src/components/MaintainersSection.tsx index 2006dd6f7..acd08d805 100644 --- a/src/components/MaintainersSection.tsx +++ b/src/components/MaintainersSection.tsx @@ -1,7 +1,7 @@ import { Link } from '@tanstack/react-router' import { LibraryId } from '~/libraries' import { getLibraryMaintainers } from '~/libraries/maintainers' -import { Button } from './Button' +import { Button } from '~/ui' import { CompactMaintainerCard } from './MaintainerCard' type MaintainersSectionProps = { @@ -33,7 +33,7 @@ export function MaintainersSection({ libraryId }: MaintainersSectionProps) { to="/$libraryId/$version/docs/contributors" params={{ libraryId, version: 'latest' } as never} > - View All Maintainers → + View All Maintainers diff --git a/src/components/NotFound.tsx b/src/components/NotFound.tsx index 5e0f60264..34c8c5106 100644 --- a/src/components/NotFound.tsx +++ b/src/components/NotFound.tsx @@ -1,6 +1,6 @@ import { Link } from '@tanstack/react-router' import { Ghost } from 'lucide-react' -import { Button } from './Button' +import { Button } from '~/ui' export function NotFound({ children }: { children?: any }) { return ( @@ -13,17 +13,10 @@ export function NotFound({ children }: { children?: any }) {

The page you are looking for does not exist.

{children || (

- -

diff --git a/src/components/NotesModerationList.tsx b/src/components/NotesModerationList.tsx index 96973f726..bc1cd8186 100644 --- a/src/components/NotesModerationList.tsx +++ b/src/components/NotesModerationList.tsx @@ -14,6 +14,7 @@ import { Spinner } from './Spinner' import type { DocFeedback } from '~/db/types' import { calculatePoints } from '~/utils/docFeedback.client' import { ExternalLink, TriangleAlert } from 'lucide-react' +import { Badge } from '~/ui' interface NotesModerationListProps { data: @@ -130,15 +131,12 @@ export function NotesModerationList({ (feedback.content.length > 100 ? '...' : '') const charCount = feedback.content.length - // Status badge styling - const statusStyles = { - pending: - 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400', - approved: - 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', - denied: - 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400', - } + // Status badge variant mapping + const statusVariants = { + pending: 'warning', + approved: 'success', + denied: 'error', + } as const return ( @@ -153,14 +151,9 @@ export function NotesModerationList({ {(page - 1) * pageSize + index + 1} - + {feedback.status} - +
diff --git a/src/components/NotesModerationTopBar.tsx b/src/components/NotesModerationTopBar.tsx index fd9c98ea0..2926e20ab 100644 --- a/src/components/NotesModerationTopBar.tsx +++ b/src/components/NotesModerationTopBar.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import { libraries } from '~/libraries' import { TopBarFilter, @@ -7,6 +6,7 @@ import { FilterDropdownSection, FilterCheckbox, } from '~/components/FilterComponents' +import { FormInput } from '~/ui' interface NotesModerationTopBarProps { filters: { @@ -128,14 +128,14 @@ export function NotesModerationTopBar({ > From - onFilterChange({ dateFrom: e.target.value || undefined }) } - className="w-full px-3 py-2 text-sm 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="text-sm" />
@@ -145,14 +145,14 @@ export function NotesModerationTopBar({ > To - onFilterChange({ dateTo: e.target.value || undefined }) } - className="w-full px-3 py-2 text-sm 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="text-sm" />
diff --git a/src/components/PartnersSection.tsx b/src/components/PartnersSection.tsx index 5e7c6fe30..effa88b64 100644 --- a/src/components/PartnersSection.tsx +++ b/src/components/PartnersSection.tsx @@ -4,7 +4,7 @@ import { partners } from '~/utils/partners' import { PartnersGrid } from './PartnersGrid' import { PartnershipCallout } from './PartnershipCallout' import { LibraryId } from '~/libraries' -import { Button } from './Button' +import { Button } from '~/ui' type PartnersSectionProps = { libraryId?: LibraryId @@ -45,7 +45,7 @@ export function PartnersSection({ : { status: 'inactive' }) as any } > - View Previous Partners → + View Previous Partners ) : null} diff --git a/src/components/RedirectVersionBanner.tsx b/src/components/RedirectVersionBanner.tsx index bf9560d21..6ca077dbe 100644 --- a/src/components/RedirectVersionBanner.tsx +++ b/src/components/RedirectVersionBanner.tsx @@ -1,7 +1,7 @@ import { useLocalStorage } from '~/utils/useLocalStorage' import { useMounted } from '~/hooks/useMounted' import { Link, useMatches } from '@tanstack/react-router' -import { Button } from './Button' +import { Button } from '~/ui' export function RedirectVersionBanner(props: { version: string @@ -43,13 +43,15 @@ export function RedirectVersionBanner(props: { to={activeMatch.fullPath} params={{ version: 'latest' } as never} replace - className="bg-black border-black hover:bg-gray-800 dark:bg-white dark:border-white dark:hover:bg-gray-200 dark:text-black text-white w-full lg:w-auto justify-center" + variant="secondary" + className="w-full lg:w-auto justify-center" > Latest diff --git a/src/components/SearchButton.tsx b/src/components/SearchButton.tsx index 29f1d0c79..78b98e364 100644 --- a/src/components/SearchButton.tsx +++ b/src/components/SearchButton.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { Command, Search } from 'lucide-react' import { twMerge } from 'tailwind-merge' import { useSearchContext } from '~/contexts/SearchContext' -import { Button } from './Button' +import { Button } from '~/ui' interface SearchButtonProps { className?: string @@ -14,6 +14,8 @@ export function SearchButton({ className }: SearchButtonProps) { return ( + )} {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) => ( - {alt - ), + 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 ( - diff --git a/src/components/admin/AdminEmptyState.tsx b/src/components/admin/AdminEmptyState.tsx index 84d9aa0a8..6fa2cc84a 100644 --- a/src/components/admin/AdminEmptyState.tsx +++ b/src/components/admin/AdminEmptyState.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { Link } from '@tanstack/react-router' +import { Button } from '~/ui' type AdminEmptyStateProps = { icon: React.ReactNode @@ -32,11 +33,8 @@ export function AdminEmptyState({ )} {action && (
- - {action.label} + +
)} diff --git a/src/components/admin/BannerEditor.tsx b/src/components/admin/BannerEditor.tsx index 9938d4bcd..4827535d2 100644 --- a/src/components/admin/BannerEditor.tsx +++ b/src/components/admin/BannerEditor.tsx @@ -22,6 +22,7 @@ import { Gift, ExternalLink, } from 'lucide-react' +import { FormInput, Button } from '~/ui' interface BannerEditorProps { banner: BannerWithMeta | null @@ -186,8 +187,6 @@ export function BannerEditor({ banner, onSave, onCancel }: BannerEditorProps) { 'px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50' const labelClass = 'block text-sm font-medium text-gray-600 dark:text-gray-400 mb-2' - const inputClass = - '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' return (
@@ -205,21 +204,14 @@ export function BannerEditor({ banner, onSave, onCancel }: BannerEditorProps) {

- - + +
@@ -250,11 +242,10 @@ export function BannerEditor({ banner, onSave, onCancel }: BannerEditorProps) { - setTitle(e.target.value)} - className={inputClass} placeholder="Enter banner title" /> @@ -271,7 +262,7 @@ export function BannerEditor({ banner, onSave, onCancel }: BannerEditorProps) { value={content} onChange={(e) => setContent(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="Optional additional details" /> @@ -301,11 +292,10 @@ export function BannerEditor({ banner, onSave, onCancel }: BannerEditorProps) { - setLinkUrl(e.target.value)} - className={inputClass} placeholder="https://example.com/page" /> @@ -318,11 +308,10 @@ export function BannerEditor({ banner, onSave, onCancel }: BannerEditorProps) { (Button text - defaults to "Learn More") - setLinkText(e.target.value)} - className={inputClass} placeholder="Learn More" /> @@ -484,7 +473,7 @@ export function BannerEditor({ banner, onSave, onCancel }: BannerEditorProps) { {/* Custom path input */}
- setNewPathPrefix(e.target.value)} @@ -495,20 +484,21 @@ export function BannerEditor({ banner, onSave, onCancel }: BannerEditorProps) { } }} placeholder="/custom/path" - className="flex-1 px-3 py-2 text-sm 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-purple-500 focus:border-transparent" + focusRing="purple" + className="flex-1 px-3 py-2 text-sm" /> - +
{/* Selected paths */} @@ -589,11 +579,11 @@ export function BannerEditor({ banner, onSave, onCancel }: BannerEditorProps) { (Optional - shows immediately if empty) - setStartsAt(e.target.value)} - 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 focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-shadow" + focusRing="orange" /> @@ -605,11 +595,11 @@ export function BannerEditor({ banner, onSave, onCancel }: BannerEditorProps) { (Optional - never expires if empty) - setExpiresAt(e.target.value)} - 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 focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-shadow" + focusRing="orange" /> @@ -621,11 +611,11 @@ export function BannerEditor({ banner, onSave, onCancel }: BannerEditorProps) { (Higher = shown first when multiple banners match) - setPriority(parseInt(e.target.value) || 0)} - 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 focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-shadow" + focusRing="orange" /> diff --git a/src/components/admin/CapabilityBadge.tsx b/src/components/admin/CapabilityBadge.tsx index e9b536575..2bfb23029 100644 --- a/src/components/admin/CapabilityBadge.tsx +++ b/src/components/admin/CapabilityBadge.tsx @@ -1,4 +1,4 @@ -import { twMerge } from 'tailwind-merge' +import { Badge } from '~/ui' import type { Capability } from '~/db/types' type CapabilityBadgeProps = { @@ -6,36 +6,27 @@ type CapabilityBadgeProps = { className?: string } -const capabilityStyles: Record = { - admin: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300', - disableAds: - 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300', - builder: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300', - feed: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300', - 'moderate-feedback': - 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300', - 'moderate-showcases': - 'bg-teal-100 text-teal-800 dark:bg-teal-900/30 dark:text-teal-300', +const capabilityVariants: Record< + string, + 'error' | 'purple' | 'info' | 'orange' | 'success' | 'teal' | 'default' +> = { + admin: 'error', + disableAds: 'purple', + builder: 'info', + feed: 'orange', + 'moderate-feedback': 'success', + 'moderate-showcases': 'teal', } -const defaultStyle = - 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' - export function CapabilityBadge({ capability, className, }: CapabilityBadgeProps) { - const style = capabilityStyles[capability] || defaultStyle + const variant = capabilityVariants[capability] || 'default' return ( - + {capability} - + ) } diff --git a/src/components/admin/FeedEntryEditor.tsx b/src/components/admin/FeedEntryEditor.tsx index c43cddc2e..8705ad5db 100644 --- a/src/components/admin/FeedEntryEditor.tsx +++ b/src/components/admin/FeedEntryEditor.tsx @@ -18,6 +18,7 @@ import { Calendar, Check, } from 'lucide-react' +import { FormInput, Button } from '~/ui' interface FeedEntryEditorProps { entry: FeedEntry | null @@ -150,8 +151,6 @@ export function FeedEntryEditor({ 'px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50' const labelClass = 'block text-sm font-medium text-gray-600 dark:text-gray-400 mb-2' - const inputClass = - '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' const chipBase = 'px-3 py-1.5 rounded-full text-sm font-medium transition-all' const chipUnselected = 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600' @@ -172,21 +171,14 @@ export function FeedEntryEditor({

- - + +
@@ -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

- +
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={ <> - {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({ - - - Edit + + + ) 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 + + } /> 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

- +
) 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 + +
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 ( - + ) }, }, @@ -323,16 +324,16 @@ function GitHubStatsAdmin() {

{cacheEntries && cacheEntries.length > 0 && ( - + )}
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 + +
@@ -482,11 +480,8 @@ function UsersTab({ View and manage individual user accounts, roles, and capabilities.

- - 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 }) => ( - + ), }, ], @@ -304,13 +305,13 @@ function NpmStatsAdmin() { 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 - + )} @@ -330,13 +325,13 @@ function RoleDetailPage() { Remove {confirmRemove.name} from role "{role?.name}"?

- - +
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 && ( - + ) } /> @@ -476,14 +455,15 @@ function RolesPage() { ))} - +
} /> @@ -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() {
- - + +
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) => (

{user.capabilities && user.capabilities.length > 0 ? ( user.capabilities.map((cap: string) => ( - + {cap} - + )) ) : ( @@ -224,9 +217,10 @@ function UserDetailPage() { key={role._id} to="/admin/roles/$roleId" params={{ roleId: role._id }} - className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 hover:bg-purple-200 dark:hover:bg-purple-900/50" > - {role.name} + + {role.name} + )) ) : ( @@ -244,12 +238,9 @@ function UserDetailPage() {
{effectiveCapabilities.length > 0 ? ( effectiveCapabilities.map((cap) => ( - + {cap} - + )) ) : ( @@ -272,15 +263,9 @@ function UserDetailPage() { Ads Disabled
- + {user.adsDisabled ? 'Yes' : 'No'} - +
@@ -288,15 +273,11 @@ function UserDetailPage() { Interested in Hiding Ads
- {user.interestedInHidingAds ? 'Yes' : 'No'} - +
diff --git a/src/routes/admin/users.tsx b/src/routes/admin/users.tsx index 0d804bcda..8530faff9 100644 --- a/src/routes/admin/users.tsx +++ b/src/routes/admin/users.tsx @@ -47,6 +47,7 @@ import { useAdminGuard } from '~/hooks/useAdminGuard' import { useToggleArray } from '~/hooks/useToggleArray' import { handleAdminError } from '~/utils/adminErrors' import { requireCapability } from '~/utils/auth.server' +import { Badge, Button } from '~/ui' // User type for table - matches the shape returned by listUsers type User = { @@ -129,12 +130,9 @@ function UserRolesCell({ return (
{(userRoles || []).map((role) => ( - + {role.name} - + ))} {(!userRoles || userRoles.length === 0) && ( @@ -163,12 +161,9 @@ function EffectiveCapabilitiesCell({ return (
{(effectiveCapabilities || []).map((capability: string) => ( - + {capability} - + ))} {(!effectiveCapabilities || effectiveCapabilities.length === 0) && ( None @@ -231,14 +226,6 @@ function UsersPage() { const sortBy = search.sortBy const sortDir = search.sortDir - const hasActiveFilters = - emailFilter !== '' || - nameFilter !== '' || - capabilityFilters.length > 0 || - noCapabilitiesFilter || - adsDisabledFilter !== 'all' || - waitlistFilter !== 'all' - const handleClearFilters = () => { navigate({ resetScroll: false, @@ -479,23 +466,6 @@ function UsersPage() { [adminSetAdsDisabled], ) - const handleCapabilityToggle = useCallback( - (capability: string) => { - const newFilters = capabilityFilters.includes(capability) - ? capabilityFilters.filter((c: string) => c !== capability) - : [...capabilityFilters, capability] - navigate({ - resetScroll: false, - search: (prev: UsersSearch) => ({ - ...prev, - cap: newFilters.length > 0 ? newFilters : undefined, - page: 0, - }), - }) - }, - [capabilityFilters, navigate], - ) - const handleSort = useCallback( (column: Column) => { const columnId = column.id @@ -627,12 +597,9 @@ function UsersPage() { ) : (
{(user.capabilities || []).map((capability: string) => ( - + {capability} - + ))} {(!user.capabilities || user.capabilities.length === 0) && ( @@ -710,15 +677,9 @@ function UsersPage() { const user = row.original const onWaitlist = Boolean(user.interestedInHidingAds) return ( - + {onWaitlist ? 'Yes' : 'No'} - + ) }, }, @@ -874,13 +835,13 @@ function UsersPage() { ))} - +
diff --git a/src/routes/blog.tsx b/src/routes/blog.tsx index 0f30ebf28..6f4bf25dd 100644 --- a/src/routes/blog.tsx +++ b/src/routes/blog.tsx @@ -1,6 +1,6 @@ import { Link, createFileRoute } from '@tanstack/react-router' import { seo } from '~/utils/seo' -import { Button } from '~/components/Button' +import { Button } from '~/ui' export const Route = createFileRoute('/blog')({ head: () => ({ diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 504cbe3f6..f2e9a7a5f 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -28,7 +28,7 @@ import { ArrowRight, Code2, Layers, Shield, Zap } from 'lucide-react' import { Card } from '~/components/Card' import LibraryCard from '~/components/LibraryCard' import { FeaturedShowcases } from '~/components/ShowcaseSection' -import { Button } from '~/components/Button' +import { Button } from '~/ui' export const textColors = [ `text-rose-500`, @@ -295,7 +295,7 @@ function Index() {
@@ -534,7 +534,7 @@ function Index() {
diff --git a/src/routes/learn.tsx b/src/routes/learn.tsx index e2e116f4c..c490ec743 100644 --- a/src/routes/learn.tsx +++ b/src/routes/learn.tsx @@ -1,7 +1,7 @@ import { Link, createFileRoute } from '@tanstack/react-router' import { seo } from '~/utils/seo' import { Users, Video, MapPin, Star } from 'lucide-react' -import { LogoQueryGG } from '~/components/LogoQueryGG' +import { LogoQueryGG } from '~/ui' import { CheckCircleIcon } from '~/components/icons/CheckCircleIcon' export const Route = createFileRoute('/learn')({ diff --git a/src/routes/oauth/authorize.tsx b/src/routes/oauth/authorize.tsx index ec9d22f2d..be10c0d1b 100644 --- a/src/routes/oauth/authorize.tsx +++ b/src/routes/oauth/authorize.tsx @@ -4,7 +4,7 @@ import * as v from 'valibot' import { createAuthorizationCode } from '~/utils/oauthClient.functions' import { getCurrentUser } from '~/utils/auth.server' import { Card } from '~/components/Card' -import { Button } from '~/components/Button' +import { Button } from '~/ui' import { useIsDark } from '~/hooks/useIsDark' import { BrandContextMenu } from '~/components/BrandContextMenu' diff --git a/src/routes/paid-support.tsx b/src/routes/paid-support.tsx index 682fd8890..39d5f37b4 100644 --- a/src/routes/paid-support.tsx +++ b/src/routes/paid-support.tsx @@ -8,6 +8,7 @@ import { MaintainerRowCard, } from '~/components/MaintainerCard' import { Grid2x2, Grid3X3, LayoutList, Mail } from 'lucide-react' +import { Button } from '~/ui' export const Route = createFileRoute('/paid-support')({ component: PaidSupportComp, @@ -136,11 +137,8 @@ function PaidSupportComp() { We also offer professional workshops on TanStack libraries, delivered remotely or in-person by our creators and maintainers.

- - 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.

- + ) : (

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 ( +
+ + Query.gg - The Official React Query Course + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) + } + + // Default (large) size - keeping original SVG content + return ( +
+ + Query.gg - The Official React Query Course + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ) +} 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 ( + {alt + ) +}) 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'