mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-07 01:13:05 +02:00
vike migration
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user