forked from iarv/mesh-forge
369 lines
14 KiB
TypeScript
369 lines
14 KiB
TypeScript
import { Checkbox } from "@/components/ui/checkbox"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import { Download, Star, Zap } from "lucide-react"
|
|
import { navigate } from "vike/client/router"
|
|
|
|
function getGitHubStarsBadgeUrl(repoUrl?: string): string | null {
|
|
if (!repoUrl) return null
|
|
try {
|
|
const url = new URL(repoUrl)
|
|
if (url.hostname === "github.com" || url.hostname === "www.github.com") {
|
|
const pathParts = url.pathname.split("/").filter(Boolean)
|
|
if (pathParts.length >= 2) {
|
|
const owner = pathParts[0]
|
|
const repo = pathParts[1]
|
|
return `https://img.shields.io/github/stars/${owner}/${repo}?style=flat&logo=github&logoColor=white&labelColor=rgb(0,0,0,0)&color=rgb(30,30,30)&label=★`
|
|
}
|
|
}
|
|
} catch {
|
|
// Invalid URL, return null
|
|
}
|
|
return null
|
|
}
|
|
|
|
interface PluginCardBaseProps {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
imageUrl?: string
|
|
featured?: boolean
|
|
repo?: string
|
|
homepage?: string
|
|
version?: string
|
|
downloads?: number
|
|
stars?: number
|
|
flashCount?: number
|
|
incompatibleReason?: string
|
|
prominent?: boolean
|
|
}
|
|
|
|
interface PluginCardToggleProps extends PluginCardBaseProps {
|
|
variant: "toggle"
|
|
isEnabled: boolean
|
|
onToggle: (enabled: boolean) => void
|
|
disabled?: boolean
|
|
enabledLabel?: string
|
|
}
|
|
|
|
interface PluginCardLinkProps extends PluginCardBaseProps {
|
|
variant: "link"
|
|
href?: string
|
|
}
|
|
|
|
interface PluginCardLinkToggleProps extends PluginCardBaseProps {
|
|
variant: "link-toggle"
|
|
isEnabled: boolean
|
|
onToggle: (enabled: boolean) => void
|
|
disabled?: boolean
|
|
enabledLabel?: string
|
|
diagnostics?: {
|
|
checked: boolean
|
|
onCheckedChange: (checked: boolean) => void
|
|
}
|
|
}
|
|
|
|
type PluginCardProps = PluginCardToggleProps | PluginCardLinkProps | PluginCardLinkToggleProps
|
|
|
|
export function PluginCard(props: PluginCardProps) {
|
|
const {
|
|
id,
|
|
name,
|
|
description,
|
|
imageUrl,
|
|
featured = false,
|
|
repo,
|
|
homepage,
|
|
version,
|
|
downloads,
|
|
stars,
|
|
flashCount,
|
|
incompatibleReason,
|
|
prominent = false,
|
|
} = props
|
|
|
|
const starsBadgeUrl = getGitHubStarsBadgeUrl(repo)
|
|
const isIncompatible = !!incompatibleReason
|
|
const isToggle = props.variant === "toggle"
|
|
const isLink = props.variant === "link"
|
|
const isLinkToggle = props.variant === "link-toggle"
|
|
|
|
const cardContent = (
|
|
<>
|
|
{isToggle ? (
|
|
<>
|
|
{/* Toggle layout: horizontal with switch on right */}
|
|
<div className="flex items-start gap-4 flex-1 min-w-0">
|
|
<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 shrink-0" />}
|
|
</div>
|
|
<p
|
|
className={`text-xs leading-relaxed text-left ${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={props.isEnabled}
|
|
onCheckedChange={props.onToggle}
|
|
disabled={props.disabled}
|
|
labelLeft="Skip"
|
|
labelRight={props.enabledLabel || "Add"}
|
|
className={props.isEnabled ? "bg-green-600" : "bg-slate-600"}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* Metadata in bottom right for toggle */}
|
|
<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>}
|
|
{flashCount !== undefined && (
|
|
<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>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* Link/link-toggle layout: vertical with image */}
|
|
<div className="flex items-start gap-3">
|
|
{imageUrl && (
|
|
<img src={imageUrl} alt={`${name} logo`} className="w-12 h-12 rounded-lg object-contain shrink-0" />
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 mb-1 flex-wrap">
|
|
<h4
|
|
className={`font-semibold text-sm ${
|
|
isIncompatible
|
|
? "text-slate-500"
|
|
: prominent
|
|
? "text-cyan-100 group-hover:text-white transition-colors"
|
|
: isLinkToggle
|
|
? ""
|
|
: "text-white group-hover:text-cyan-400 transition-colors"
|
|
}`}
|
|
>
|
|
{name}
|
|
</h4>
|
|
{featured &&
|
|
(isLinkToggle ? (
|
|
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400 shrink-0" />
|
|
) : (
|
|
<span className="px-1.5 py-0.5 text-xs font-medium text-green-400 bg-green-400/10 border border-green-400/20 rounded">
|
|
Featured
|
|
</span>
|
|
))}
|
|
</div>
|
|
<p
|
|
className={`text-xs leading-relaxed text-left ${
|
|
isIncompatible ? "text-slate-500" : prominent ? "text-cyan-200/90" : "text-slate-400"
|
|
}`}
|
|
>
|
|
{description}
|
|
</p>
|
|
{isIncompatible && incompatibleReason && (
|
|
<p className="text-xs text-red-400 mt-1 font-medium">{incompatibleReason}</p>
|
|
)}
|
|
{/* Diagnostics checkbox - only show for link-toggle variant when enabled */}
|
|
{isLinkToggle && props.isEnabled && props.diagnostics && (
|
|
<div className="mt-2">
|
|
<label className="flex items-start gap-2 cursor-pointer group" htmlFor={`${id}-diagnostics`}>
|
|
<Checkbox
|
|
id={`${id}-diagnostics`}
|
|
checked={props.diagnostics.checked}
|
|
onCheckedChange={props.diagnostics.onCheckedChange}
|
|
className="mt-0.5"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-xs font-medium text-slate-300 group-hover:text-white transition-colors">
|
|
Include Diagnostics
|
|
</div>
|
|
<div className="text-xs text-slate-500 mt-0.5">Enable diagnostic logging for this plugin</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer with metadata and toggle */}
|
|
<div className="flex items-center justify-between gap-3 mt-auto pt-2 border-t border-slate-700/50">
|
|
{/* Metadata row */}
|
|
<div className="flex items-center gap-3 flex-wrap text-xs text-slate-400">
|
|
{version && <span className="text-slate-500">v{version}</span>}
|
|
{isLinkToggle && flashCount !== undefined && (
|
|
<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>
|
|
)}
|
|
{isLink && downloads !== undefined && (
|
|
<div className="flex items-center gap-1">
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span>{downloads.toLocaleString()}</span>
|
|
</div>
|
|
)}
|
|
{homepage &&
|
|
homepage !== repo &&
|
|
(isLink || isLinkToggle) &&
|
|
(isLink ? (
|
|
<button
|
|
type="button"
|
|
onClick={e => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
window.open(homepage, "_blank", "noopener,noreferrer")
|
|
}}
|
|
className="hover:opacity-80 transition-opacity"
|
|
aria-label="Homepage"
|
|
>
|
|
<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="Homepage"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
) : (
|
|
<a
|
|
href={homepage}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={e => e.stopPropagation()}
|
|
className="hover:opacity-80 transition-opacity"
|
|
>
|
|
<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="Homepage"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
|
|
/>
|
|
</svg>
|
|
</a>
|
|
))}
|
|
{starsBadgeUrl &&
|
|
repo &&
|
|
(isLink ? (
|
|
<button
|
|
type="button"
|
|
onClick={e => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
window.open(repo, "_blank", "noopener,noreferrer")
|
|
}}
|
|
className="hover:opacity-80 transition-opacity"
|
|
aria-label="GitHub repository"
|
|
>
|
|
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
|
</button>
|
|
) : (
|
|
<a
|
|
href={repo}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={e => e.stopPropagation()}
|
|
className="hover:opacity-80 transition-opacity"
|
|
>
|
|
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
|
|
</a>
|
|
))}
|
|
</div>
|
|
{/* Toggle switch or Build Now button */}
|
|
{isLinkToggle ? (
|
|
<Switch
|
|
checked={props.isEnabled}
|
|
onCheckedChange={props.onToggle}
|
|
disabled={props.disabled}
|
|
labelLeft="Skip"
|
|
labelRight={props.enabledLabel || "Add"}
|
|
className={props.isEnabled ? "bg-green-600" : "bg-slate-600"}
|
|
/>
|
|
) : isLink ? (
|
|
<button
|
|
onClick={e => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
navigate(`/builds?plugin=${id}`)
|
|
}}
|
|
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer shrink-0"
|
|
>
|
|
<Zap className="w-3 h-3" />
|
|
Build Now
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
const baseClassName = `relative flex ${isToggle ? "items-start gap-4" : "flex-col"} p-4 rounded-lg border-2 transition-colors h-full ${
|
|
isIncompatible
|
|
? "border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed"
|
|
: prominent
|
|
? "border-cyan-400 bg-gradient-to-br from-cyan-500/30 via-cyan-600/20 to-blue-600/30 hover:from-cyan-500/40 hover:via-cyan-600/30 hover:to-blue-600/40 hover:border-cyan-300 shadow-xl shadow-cyan-500/30"
|
|
: "border-slate-700 bg-slate-900/50 hover:border-slate-600"
|
|
} ${isLink ? "group" : ""}`
|
|
|
|
if (isLink) {
|
|
const href = props.href || `/builds?plugin=${id}`
|
|
return (
|
|
<a href={href} className={baseClassName}>
|
|
{cardContent}
|
|
</a>
|
|
)
|
|
}
|
|
|
|
return <div className={baseClassName}>{cardContent}</div>
|
|
}
|
|
|
|
// Export convenience wrappers for backward compatibility
|
|
export function PluginToggle(props: Omit<PluginCardToggleProps, "variant">) {
|
|
return <PluginCard {...props} variant="toggle" />
|
|
}
|