diff --git a/assets/custom-build.pxd b/assets/custom-build.pxd new file mode 100644 index 0000000..bc57d0a Binary files /dev/null and b/assets/custom-build.pxd differ diff --git a/components/BuildDownloadButton.tsx b/components/BuildDownloadButton.tsx index 31a287f..babd8d7 100644 --- a/components/BuildDownloadButton.tsx +++ b/components/BuildDownloadButton.tsx @@ -1,11 +1,11 @@ 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" import { useMutation } from "convex/react" import { useState } from "react" import { toast } from "sonner" -import { api } from "../convex/_generated/api" -import type { Doc } from "../convex/_generated/dataModel" -import { ArtifactType } from "../convex/builds" interface BuildDownloadButtonProps { build: Doc<"builds"> diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 3a6511b..e6a2db4 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -1,10 +1,10 @@ +import favicon from "@/assets/favicon-96x96.png" import { DiscordButton } from "@/components/DiscordButton" import { RedditButton } from "@/components/RedditButton" import { Button } from "@/components/ui/button" +import { api } from "@/convex/_generated/api" import { useAuthActions } from "@convex-dev/auth/react" import { Authenticated, Unauthenticated, useQuery } from "convex/react" -import favicon from "../assets/favicon-96x96.png" -import { api } from "../convex/_generated/api" export default function Navbar() { const { signOut, signIn } = useAuthActions() @@ -25,6 +25,9 @@ export default function Navbar() { Docs + + Plugins + {isAdmin && ( diff --git a/components/PluginCard.tsx b/components/PluginCard.tsx new file mode 100644 index 0000000..622b4bc --- /dev/null +++ b/components/PluginCard.tsx @@ -0,0 +1,304 @@ +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 +} + +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 */} +
+
+
+

{name}

+ {featured && } +
+

+ {description} +

+ {isIncompatible && incompatibleReason && ( +

{incompatibleReason}

+ )} +
+
+ +
+
+ {/* Metadata in bottom right for toggle */} +
+ {version && v{version}} + {flashCount !== undefined && ( +
+ + + + {flashCount} +
+ )} +
+ + ) : ( + <> + {/* Link/link-toggle layout: vertical with image */} +
+ {imageUrl && ( + {`${name} + )} +
+
+

+ {name} +

+ {featured && + (isLinkToggle ? ( + + ) : ( + + Featured + + ))} +
+

+ {description} +

+ {isIncompatible && incompatibleReason && ( +

{incompatibleReason}

+ )} +
+
+ + {/* Metadata row */} +
+ {version && v{version}} + {isLinkToggle && flashCount !== undefined && ( +
+ + + + {flashCount} +
+ )} + {isLink && downloads !== undefined && ( +
+ + {downloads.toLocaleString()} +
+ )} + {homepage && homepage !== repo && (isLink || isLinkToggle) && ( +
e.stopPropagation()} + className="hover:opacity-80 transition-opacity" + > + + + + + )} + {starsBadgeUrl && repo && ( + e.stopPropagation()} + className="hover:opacity-80 transition-opacity" + > + GitHub stars + + )} +
+ {/* Build Now button - absolutely positioned in lower right */} + {isLink && ( +
+ +
+ )} + {/* Toggle switch - absolutely positioned in lower right */} + {isLinkToggle && ( +
+
+ +
+
+ )} + + )} + + ) + + const baseClassName = `relative flex ${isToggle ? "items-start gap-4" : "flex-col gap-3"} 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/new?plugin=${id}` + return ( + + {cardContent} + + ) + } + + return
{cardContent}
+} + +// Export convenience wrappers for backward compatibility +export function PluginToggle(props: Omit) { + return +} diff --git a/components/PluginToggle.tsx b/components/PluginToggle.tsx deleted file mode 100644 index 8127dbb..0000000 --- a/components/PluginToggle.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { Switch } from "@/components/ui/switch" -import { ExternalLink, Star } from "lucide-react" - -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 ( -
- {/* Flash count and homepage links in lower right */} -
- {version && v{version}} -
- - - - {flashCount} -
- {homepage && ( - e.stopPropagation()} - > - - - )} -
-
-
-

{name}

- {featured && } -
-

- {description} -

- {isIncompatible && incompatibleReason && ( -

{incompatibleReason}

- )} -
-
- -
-
- ) -} diff --git a/components/ProfileCard.tsx b/components/ProfileCard.tsx deleted file mode 100644 index 9b7cb6e..0000000 --- a/components/ProfileCard.tsx +++ /dev/null @@ -1,39 +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 ( -
- {version} - - {normalizedCount} {normalizedLabel} - -
- ) -} - -interface ProfileCardContentProps { - profile: Doc<"profiles"> -} - -export function ProfileCardContent({ profile }: ProfileCardContentProps) { - const flashCount = profile.flashCount ?? 0 - return ( - <> -
-

{profile.name}

-

{profile.description}

-
- - - ) -} diff --git a/components/ProfileEditor.tsx b/components/ProfileEditor.tsx deleted file mode 100644 index b134a59..0000000 --- a/components/ProfileEditor.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { Input } from "@/components/ui/input" -import { useMutation } from "convex/react" -import { useForm } from "react-hook-form" -import { VERSIONS } from "../constants/versions" -import { api } from "../convex/_generated/api" -import type { Doc } from "../convex/_generated/dataModel" -import modulesData from "../convex/modules.json" -import { ModuleToggle } from "./ModuleToggle" - -// Form values use flattened config for UI, but will be transformed to nested on submit -type ProfileFormValues = Omit, "_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({ - 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 ( -
-
-
- - - {errors.name &&

{errors.name.message}

} -
-
- - -
-
- -
- -