vike migration

This commit is contained in:
Ben Allfree
2025-12-06 17:12:03 -08:00
parent 69822a12b9
commit 3a543de36b
84 changed files with 1995 additions and 2622 deletions
-106
View File
@@ -1,106 +0,0 @@
import { useMutation } from 'convex/react'
import { useState } from 'react'
import { toast } from 'sonner'
import { SourceAvailable } from '@/components/SourceAvailable'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
import { ArtifactType } from '../../convex/builds'
interface BuildDownloadButtonProps {
build: Doc<'builds'>
type: ArtifactType
variant?: 'default' | 'outline'
className?: string
}
export function BuildDownloadButton({
build,
type,
variant,
className,
}: BuildDownloadButtonProps) {
const generateDownloadUrl = useMutation(api.builds.generateDownloadUrl)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Default styling based on type
const defaultVariant =
variant ?? (type === ArtifactType.Firmware ? 'default' : 'outline')
const defaultClassName =
className ??
(type === ArtifactType.Firmware
? 'bg-cyan-600 hover:bg-cyan-700'
: 'bg-slate-700 hover:bg-slate-600')
const handleDownload = async () => {
setError(null)
setIsLoading(true)
try {
const url = await generateDownloadUrl({
buildId: build._id,
artifactType: type,
})
window.location.href = url
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const errorMsg =
type === ArtifactType.Firmware
? 'Failed to generate download link.'
: 'Failed to generate source download link.'
setError(errorMsg)
toast.error(errorMsg, {
description: message,
})
} finally {
setIsLoading(false)
}
}
if (type === ArtifactType.Firmware && !build.buildHash) return null
const button = (
<div className="space-y-2">
<Button
onClick={handleDownload}
disabled={isLoading}
variant={defaultVariant}
className={defaultClassName}
>
Download {type === ArtifactType.Firmware ? 'firmware' : 'source'}
</Button>
{type === ArtifactType.Firmware && (
<p className="text-xs text-slate-400 text-center">
Need help flashing?{' '}
<a
href="https://github.com/MeshEnvy/mesh-forge/discussions/5"
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 underline"
>
ESP32
</a>
{' '}and{' '}
<a
href="https://github.com/MeshEnvy/mesh-forge/discussions/6"
target="_blank"
rel="noopener noreferrer"
className="text-cyan-400 hover:text-cyan-300 underline"
>
nRF52
</a>
</p>
)}
{error && <p className="text-sm text-red-400">{error}</p>}
</div>
)
// For source downloads, only show when sourcePath is available
if (type === ArtifactType.Source) {
return (
<SourceAvailable sourcePath={build.sourcePath}>{button}</SourceAvailable>
)
}
return button
}
-47
View File
@@ -1,47 +0,0 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function DiscordIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<path d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.1.1 0 0 0-.07.03c-.18.33-.39.76-.53 1.09a16.1 16.1 0 0 0-4.8 0c-.14-.34-.35-.76-.54-1.09c-.01-.02-.04-.03-.07-.03c-1.5.26-2.93.71-4.27 1.33c-.01 0-.02.01-.03.02c-2.72 4.07-3.47 8.03-3.1 11.95c0 .02.01.04.03.05c1.8 1.32 3.53 2.12 5.24 2.65c.03.01.06 0 .07-.02c.4-.55.76-1.13 1.07-1.74c.02-.04 0-.08-.04-.09c-.57-.22-1.11-.48-1.64-.78c-.04-.02-.04-.08-.01-.11c.11-.08.22-.17.33-.25c.02-.02.05-.02.07-.01c3.44 1.57 7.15 1.57 10.55 0c.02-.01.05-.01.07.01c.11.09.22.17.33.26c.04.03.04.09-.01.11c-.52.31-1.07.56-1.64.78c-.04.01-.05.06-.04.09c.32.61.68 1.19 1.07 1.74c.03.01.06.02.09.01c1.72-.53 3.45-1.33 5.25-2.65c.02-.01.03-.03.03-.05c.44-4.53-.73-8.46-3.1-11.95c-.01-.01-.02-.02-.04-.02M8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.84 2.12-1.89 2.12m6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12c0 1.17-.83 2.12-1.89 2.12" />
</svg>
)
}
interface DiscordButtonProps {
variant?: 'default' | 'outline' | 'ghost' | 'link' | 'destructive'
size?: 'default' | 'sm' | 'lg' | 'icon'
className?: string
}
export function DiscordButton({
variant = 'outline',
size,
className,
}: DiscordButtonProps) {
return (
<a
href="https://discord.gg/8KgJpvjfaJ"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant={variant}
size={size}
className={cn('flex items-center gap-2', className)}
>
<DiscordIcon className="w-4 h-4" />
Discord
</Button>
</a>
)
}
-62
View File
@@ -1,62 +0,0 @@
interface ModuleCardProps {
name: string
description: string
selected: boolean
onClick: () => void
}
export function ModuleCard({
name,
description,
selected,
onClick,
}: ModuleCardProps) {
return (
<button
type="button"
onClick={onClick}
className={`
w-full text-left p-4 rounded-lg border-2 transition-all
${
selected
? 'border-blue-500 bg-blue-500/10'
: 'border-slate-700 bg-slate-900/50 hover:border-slate-600'
}
`}
>
<div className="flex items-start gap-3">
<div className="mt-1">
<div
className={`
w-5 h-5 rounded border-2 flex items-center justify-center
${selected ? 'border-blue-500 bg-blue-500' : 'border-slate-500'}
`}
>
{selected && (
<svg
className="w-3 h-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<title>Checkmark</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</div>
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm mb-1">{name}</h4>
<p className="text-xs text-slate-400 leading-relaxed">
{description}
</p>
</div>
</div>
</button>
)
}
-34
View File
@@ -1,34 +0,0 @@
import { Switch } from '@/components/ui/switch'
interface ModuleToggleProps {
id: string
name: string
description: string
isExcluded: boolean
onToggle: (excluded: boolean) => void
}
export function ModuleToggle({
name,
description,
isExcluded,
onToggle,
}: ModuleToggleProps) {
return (
<div className="flex items-start gap-4 p-4 rounded-lg border-2 border-slate-700 bg-slate-900/50 hover:border-slate-600 transition-colors">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm mb-1">{name}</h4>
<p className="text-xs text-slate-400 leading-relaxed">{description}</p>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Switch
checked={isExcluded}
onCheckedChange={onToggle}
labelLeft="Default"
labelRight="Excluded"
className={isExcluded ? 'bg-orange-600' : 'bg-slate-600'}
/>
</div>
</div>
)
}
-77
View File
@@ -1,77 +0,0 @@
import { useAuthActions } from '@convex-dev/auth/react'
import { Authenticated, Unauthenticated, useQuery } from 'convex/react'
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { DiscordButton } from '@/components/DiscordButton'
import { RedditButton } from '@/components/RedditButton'
import { api } from '../../convex/_generated/api'
export default function Navbar() {
const { signOut, signIn } = useAuthActions()
const isAdmin = useQuery(api.admin.isAdmin)
return (
<nav className="border-b border-slate-800 bg-slate-950">
<div className="max-w-7xl mx-auto px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-8">
<Link
to="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity"
>
<img
src="/favicon-96x96.png"
alt="Mesh Forge logo"
className="h-10 w-10 rounded-lg"
/>
<span className="text-2xl font-bold bg-gradient-to-r from-cyan-400 to-blue-600 bg-clip-text text-transparent">
Mesh Forge
</span>
</Link>
<div className="flex items-center gap-4">
<Authenticated>
<Link
to="/dashboard"
className="text-slate-300 hover:text-white transition-colors"
>
Dashboard
</Link>
{isAdmin && (
<Link
to="/admin"
className="text-slate-300 hover:text-white transition-colors"
>
Admin
</Link>
)}
</Authenticated>
</div>
</div>
<div className="flex items-center gap-4">
<DiscordButton
variant="default"
className="bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white border-0 shadow-lg shadow-purple-500/50"
/>
<RedditButton
variant="default"
className="bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 text-white border-0 shadow-lg shadow-orange-500/50"
/>
<Unauthenticated>
<Button
onClick={() => signIn('google', { redirectTo: window.location.href })}
className="bg-cyan-600 hover:bg-cyan-700"
>
Sign In
</Button>
</Unauthenticated>
<Authenticated>
<Button variant="outline" onClick={() => signOut()}>
Sign Out
</Button>
</Authenticated>
</div>
</div>
</div>
</nav>
)
}
-102
View File
@@ -1,102 +0,0 @@
import { ExternalLink, Star } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
interface PluginToggleProps {
id: string
name: string
description: string
isEnabled: boolean
onToggle: (enabled: boolean) => void
featured?: boolean
flashCount?: number
homepage?: string
version?: string
disabled?: boolean
enabledLabel?: string
incompatibleReason?: string
}
export function PluginToggle({
name,
description,
isEnabled,
onToggle,
featured = false,
flashCount = 0,
homepage,
version,
disabled = false,
enabledLabel = 'Add',
incompatibleReason,
}: PluginToggleProps) {
const isIncompatible = !!incompatibleReason
return (
<div
className={`relative flex items-start gap-4 p-4 rounded-lg border-2 transition-colors ${
isIncompatible
? 'border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed'
: 'border-slate-700 bg-slate-900/50 hover:border-slate-600'
}`}
>
{/* Flash count and homepage links in lower right */}
<div className="absolute bottom-2 right-2 flex items-center gap-3 text-xs text-slate-400 z-10">
{version && <span className="text-slate-500">v{version}</span>}
<div className="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
role="img"
aria-label="Download"
>
<path d="m14 2l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm4 18V9h-5V4H6v16zm-6-1l-4-4h2.5v-3h3v3H16z" />
</svg>
<span>{flashCount}</span>
</div>
{homepage && (
<a
href={homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-slate-400 hover:text-slate-300 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<h4 className={`font-semibold text-sm ${isIncompatible ? 'text-slate-500' : ''}`}>
{name}
</h4>
{featured && (
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
)}
</div>
<p className={`text-xs leading-relaxed ${isIncompatible ? 'text-slate-500' : 'text-slate-400'}`}>
{description}
</p>
{isIncompatible && incompatibleReason && (
<p className="text-xs text-red-400 mt-1 font-medium">
{incompatibleReason}
</p>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Switch
checked={isEnabled}
onCheckedChange={onToggle}
disabled={disabled}
labelLeft="Skip"
labelRight={enabledLabel}
className={isEnabled ? 'bg-green-600' : 'bg-slate-600'}
/>
</div>
</div>
)
}
-52
View File
@@ -1,52 +0,0 @@
import type { Doc } from '../../convex/_generated/dataModel'
export const profileCardClasses =
'border border-slate-800 rounded-lg p-6 bg-slate-900/50 flex flex-col gap-4'
interface ProfilePillsProps {
version: string
flashCount?: number
flashLabel?: string
}
export function ProfileStatisticPills({
version,
flashCount,
flashLabel,
}: ProfilePillsProps) {
const normalizedCount = flashCount ?? 0
const normalizedLabel =
flashLabel ?? (normalizedCount === 1 ? 'flash' : 'flashes')
return (
<div className="flex items-center justify-between text-xs font-semibold uppercase tracking-wide">
<span className="inline-flex items-center rounded-full bg-slate-800/80 text-slate-200 px-3 py-1">
{version}
</span>
<span className="inline-flex items-center rounded-full bg-cyan-500/10 text-cyan-300 px-3 py-1">
{normalizedCount} {normalizedLabel}
</span>
</div>
)
}
interface ProfileCardContentProps {
profile: Doc<'profiles'>
}
export function ProfileCardContent({ profile }: ProfileCardContentProps) {
const flashCount = profile.flashCount ?? 0
return (
<>
<div className="flex-1">
<h3 className="text-xl font-semibold mb-2">{profile.name}</h3>
<p className="text-slate-300 text-sm leading-relaxed">
{profile.description}
</p>
</div>
<ProfileStatisticPills
version={profile.config.version}
flashCount={flashCount}
/>
</>
)
}
-187
View File
@@ -1,187 +0,0 @@
import { useMutation } from 'convex/react'
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { api } from '../../convex/_generated/api'
import type { Doc } from '../../convex/_generated/dataModel'
import modulesData from '../../convex/modules.json'
import { VERSIONS } from '../constants/versions'
import { ModuleToggle } from './ModuleToggle'
// Form values use flattened config for UI, but will be transformed to nested on submit
type ProfileFormValues = Omit<
Doc<'profiles'>,
'_id' | '_creationTime' | 'userId' | 'flashCount' | 'updatedAt'
>
interface ProfileEditorProps {
initialData?: Doc<'profiles'>
onSave: () => void
onCancel: () => void
}
export default function ProfileEditor({
initialData,
onSave,
onCancel,
}: ProfileEditorProps) {
const upsertProfile = useMutation(api.profiles.upsert)
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<ProfileFormValues>({
defaultValues: {
name: initialData?.name || '',
description: initialData?.description || '',
config: {
version: VERSIONS[0],
modulesExcluded: {},
target: '',
...initialData?.config,
},
isPublic: initialData?.isPublic ?? true,
},
})
const onSubmit = async (data: ProfileFormValues) => {
await upsertProfile({
id: initialData?._id,
name: data.name,
description: data.description,
config: data.config,
isPublic: data.isPublic,
})
onSave()
}
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="space-y-6 bg-slate-900 p-6 rounded-lg border border-slate-800"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
Profile Name
</label>
<Input
id="name"
{...register('name', { required: 'Profile name is required' })}
className="bg-slate-950 border-slate-800"
placeholder="e.g. Solar Repeater"
/>
{errors.name && (
<p className="mt-1 text-sm text-red-400">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="version" className="block text-sm font-medium mb-2">
Firmware Version
</label>
<select
id="version"
{...register('config.version')}
className="w-full h-10 px-3 rounded-md border border-slate-800 bg-slate-950 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
>
{VERSIONS.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</div>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-2">
Description
</label>
<textarea
id="description"
{...register('description', {
required: 'Profile description is required',
})}
className="w-full min-h-[120px] rounded-md border border-slate-800 bg-slate-950 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
placeholder="Describe what this profile is best suited for"
/>
{errors.description && (
<p className="mt-1 text-sm text-red-400">
{errors.description.message}
</p>
)}
</div>
<div>
<div className="flex items-center space-x-2">
<Checkbox
id="isPublic"
checked={watch('isPublic')}
onCheckedChange={(checked) => setValue('isPublic', !!checked)}
disabled
/>
<label
htmlFor="isPublic"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
Make profile public
</label>
</div>
<p className="text-xs text-slate-400 mt-1 ml-6">
Public profiles are visible to everyone on the home page
</p>
</div>
<div className="space-y-6">
<div>
<div className="mb-4">
<h3 className="text-lg font-medium">Modules</h3>
<p className="text-sm text-slate-400">
Modules are included by default if supported by your target.
Toggle to exclude modules you don't need.
</p>
</div>
<div className="flex flex-col gap-2">
{modulesData.modules.map((module) => {
// Flattened config: config[id] === true -> Explicitly Excluded
// config[id] === undefined/false -> Default (included if target supports)
const currentConfig = watch('config') as Doc<'builds'>['config']
const configValue = currentConfig.modulesExcluded[module.id]
const isExcluded = configValue === true
return (
<ModuleToggle
key={module.id}
id={module.id}
name={module.name}
description={module.description}
isExcluded={isExcluded}
onToggle={(excluded) => {
const newConfig = { ...currentConfig }
if (excluded) {
newConfig.modulesExcluded[module.id] = true
} else {
delete newConfig.modulesExcluded[module.id]
}
setValue('config', newConfig)
}}
/>
)
})}
</div>
</div>
</div>
<div className="flex justify-end gap-4 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Save Profile</Button>
</div>
</form>
)
}
-168
View File
@@ -1,168 +0,0 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function RedditIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<mask id="SVGfUZuVbjp">
<g fill="#fff">
<path
fillOpacity="0"
stroke="#fff"
strokeDasharray="48"
strokeDashoffset="48"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 9.42c4.42 0 8 2.37 8 5.29c0 2.92 -3.58 5.29 -8 5.29c-4.42 0 -8 -2.37 -8 -5.29c0 -2.92 3.58 -5.29 8 -5.29Z"
>
<animate
fill="freeze"
attributeName="fill-opacity"
begin="0.6s"
dur="0.4s"
values="0;1"
/>
<animate
fill="freeze"
attributeName="stroke-dashoffset"
dur="0.6s"
values="48;0"
/>
</path>
<circle cx="7.24" cy="11.97" r="2.24" opacity="0">
<animate
fill="freeze"
attributeName="cx"
begin="1s"
dur="0.2s"
values="7.24;3.94"
/>
<set fill="freeze" attributeName="opacity" begin="1s" to="1" />
</circle>
<circle cx="16.76" cy="11.97" r="2.24" opacity="0">
<animate
fill="freeze"
attributeName="cx"
begin="1s"
dur="0.2s"
values="16.76;20.06"
/>
<set fill="freeze" attributeName="opacity" begin="1s" to="1" />
</circle>
<circle cx="18.45" cy="4.23" r="1.61" opacity="0">
<animate
attributeName="cx"
begin="2.4s"
dur="6s"
repeatCount="indefinite"
values="18.45;5.75;18.45"
/>
<set fill="freeze" attributeName="opacity" begin="2.6s" to="1" />
</circle>
</g>
<path
fill="none"
stroke="#fff"
strokeDasharray="12"
strokeDashoffset="12"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth=".8"
d="M12 8.75L13.18 3.11L18.21 4.18"
>
<animate
attributeName="d"
begin="2.4s"
dur="6s"
repeatCount="indefinite"
values="M12 8.75L13.18 3.11L18.21 4.18;M12 8.75L12 2L12 4.18;M12 8.75L10.82 3.11L5.79 4.18;M12 8.75L12 2L12 4.18;M12 8.75L13.18 3.11L18.21 4.18"
/>
<animate
fill="freeze"
attributeName="stroke-dashoffset"
begin="2.4s"
dur="0.2s"
values="12;0"
/>
</path>
<g fillOpacity="0">
<circle cx="8.45" cy="13.59" r="1.61">
<animate
fill="freeze"
attributeName="fill-opacity"
begin="1.2s"
dur="0.4s"
values="0;1"
/>
</circle>
<circle cx="15.55" cy="13.59" r="1.61">
<animate
fill="freeze"
attributeName="fill-opacity"
begin="1.6s"
dur="0.4s"
values="0;1"
/>
</circle>
</g>
<path
fill="none"
stroke="#000"
strokeDasharray="10"
strokeDashoffset="10"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth=".8"
d="M8.47 17.52c0 0 0.94 1.06 3.53 1.06c2.58 0 3.53 -1.06 3.53 -1.06"
>
<animate
fill="freeze"
attributeName="stroke-dashoffset"
begin="2s"
dur="0.2s"
values="10;0"
/>
</path>
</mask>
<rect width="24" height="24" fill="currentColor" mask="url(#SVGfUZuVbjp)" />
</svg>
)
}
interface RedditButtonProps {
variant?: 'default' | 'outline' | 'ghost' | 'link' | 'destructive'
size?: 'default' | 'sm' | 'lg' | 'icon'
className?: string
}
export function RedditButton({
variant = 'outline',
size,
className,
}: RedditButtonProps) {
return (
<a
href="https://www.reddit.com/r/MeshForge/"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant={variant}
size={size}
className={cn('flex items-center gap-2', className)}
>
<RedditIcon className="w-4 h-4" />
Reddit
</Button>
</a>
)
}
-19
View File
@@ -1,19 +0,0 @@
interface SourceAvailableProps {
sourcePath: string | undefined
children: React.ReactNode
}
/**
* Component that only renders children when sourcePath is available.
* Uses the sourcePath field from the build instead of polling.
*/
export function SourceAvailable({
sourcePath,
children,
}: SourceAvailableProps) {
if (!sourcePath) {
return null
}
return <>{children}</>
}
-57
View File
@@ -1,57 +0,0 @@
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
-28
View File
@@ -1,28 +0,0 @@
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { Check } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn('grid place-content-center text-current')}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
-22
View File
@@ -1,22 +0,0 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }
-29
View File
@@ -1,29 +0,0 @@
import { useTheme } from 'next-themes'
import { Toaster as Sonner } from 'sonner'
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
)
}
export { Toaster }
-51
View File
@@ -1,51 +0,0 @@
import { cn } from '@/lib/utils'
interface SwitchProps {
checked: boolean
onCheckedChange: (checked: boolean) => void
disabled?: boolean
className?: string
labelLeft?: string
labelRight?: string
}
export function Switch({
checked,
onCheckedChange,
disabled = false,
className,
labelLeft,
labelRight,
}: SwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-8 w-24 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 disabled:cursor-not-allowed disabled:opacity-50',
!className && (checked ? 'bg-red-600' : 'bg-slate-600'),
className
)}
>
<span
className={cn(
'inline-block h-6 w-6 transform rounded-full bg-white transition-transform',
checked ? 'translate-x-[68px]' : 'translate-x-1'
)}
/>
{checked && labelRight && (
<span className="absolute left-2 text-xs font-medium text-white">
{labelRight}
</span>
)}
{!checked && labelLeft && (
<span className="absolute right-2 text-xs font-medium text-white">
{labelLeft}
</span>
)}
</button>
)
}