- Replace inline URL input with a Connect button that opens a dialog - Unified search/URL input: filters preset list as you type, switches to detect-button mode when the input looks like a URL - Preset grid moved into the dialog with a fixed-height scroll area and a zero state when filters return nothing - Empty state on the page now points users at the Connect button - Move McpInstallCard below the sources list - Vite watch config: poll + un-ignore workspace symlinks so edits in packages/* trigger HMR through the apps/local dev server
Unified · Split
| @@ -107,6 +107,17 @@ export default defineConfig({ · 106 unmodified lines | |||
| 107 | server: { | 107 | server: { |
| 108 | port: parseInt(process.env.PORT ?? "5173", 10), | 108 | port: parseInt(process.env.PORT ?? "5173", 10), |
| 109 | host: "127.0.0.1", | 109 | host: "127.0.0.1", |
| 110 | watch: { | ||
| 111 | // Workspace packages live under packages/ and are symlinked into | ||
| 112 | // node_modules. Without this, chokidar treats them as ordinary | ||
| 113 | // node_modules and skips watching, so edits to e.g. | ||
| 114 | // packages/react/src/pages/sources.tsx don't trigger HMR. | ||
| 115 | ignored: ["!**/node_modules/@executor-js/**"], | ||
| 116 | // WSL2 + symlinked workspace packages can drop inotify events; | ||
| 117 | // polling is slower but reliable. | ||
| 118 | usePolling: true, | ||
| 119 | interval: 200, | ||
| 120 | }, | ||
| 110 | }, | 121 | }, |
| 111 | plugins: [ | 122 | plugins: [ |
| 112 | tailwindcss(), | 123 | tailwindcss(), |
| @@ -1,7 +1,8 @@ | |||
| 1 | import { Suspense, useState, useCallback, useMemo } from "react"; | 1 | import { Suspense, useCallback, useMemo, useState } from "react"; |
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import { useAtomSet } from "@effect/atom-react"; | 3 | import { useAtomSet } from "@effect/atom-react"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | import { PlusIcon } from "lucide-react"; | ||
| 5 | import type { SourceDetectionResult } from "@executor-js/sdk"; | 6 | import type { SourceDetectionResult } from "@executor-js/sdk"; |
| 6 | import { | 7 | import { |
| 7 | useSourcePlugins, | 8 | useSourcePlugins, |
| @@ -15,17 +16,22 @@ import { McpInstallCard } from "../components/mcp-install-card"; · 7 unmodified lines | |||
| 15 | import { Button } from "../components/button"; | 16 | import { Button } from "../components/button"; |
| 16 | import { Badge } from "../components/badge"; | 17 | import { Badge } from "../components/badge"; |
| 17 | import { Input } from "../components/input"; | 18 | import { Input } from "../components/input"; |
| 19 | import { | ||
| 20 | Dialog, | ||
| 21 | DialogContent, | ||
| 22 | DialogDescription, | ||
| 23 | DialogHeader, | ||
| 24 | DialogTitle, | ||
| 25 | } from "../components/dialog"; | ||
| 18 | import { | 26 | import { |
| 19 | CardStack, | 27 | CardStack, |
| 20 | CardStackHeader, | ||
| 21 | CardStackContent, | 28 | CardStackContent, |
| 22 | CardStackEntry, | 29 | CardStackEntry, |
| 23 | CardStackEntryField, | 30 | CardStackEntryActions, |
| 24 | CardStackEntryMedia, | ||
| 25 | CardStackEntryContent, | 31 | CardStackEntryContent, |
| 26 | CardStackEntryTitle, | ||
| 27 | CardStackEntryDescription, | 32 | CardStackEntryDescription, |
| 28 | CardStackEntryActions, | 33 | CardStackEntryMedia, |
| 34 | CardStackEntryTitle, | ||
| 29 | } from "../components/card-stack"; | 35 | } from "../components/card-stack"; |
| 30 | import { SourceFavicon } from "../components/source-favicon"; | 36 | import { SourceFavicon } from "../components/source-favicon"; |
| 31 | import { Skeleton } from "../components/skeleton"; | 37 | import { Skeleton } from "../components/skeleton"; |
| @@ -53,18 +59,104 @@ const bestDetection = ( · 21 unmodified lines | |||
| 53 | // --------------------------------------------------------------------------- | 59 | // --------------------------------------------------------------------------- |
| 54 | 60 | ||
| 55 | export function SourcesPage() { | 61 | export function SourcesPage() { |
| 56 | const sourcePlugins = useSourcePlugins(); | ||
| 57 | const [url, setUrl] = useState(""); | ||
| 58 | const [detecting, setDetecting] = useState(false); | ||
| 59 | const [error, setError] = useState<string | null>(null); | ||
| 60 | |||
| 61 | const scopeId = useScope(); | 62 | const scopeId = useScope(); |
| 62 | const sources = useSourcesWithPending(scopeId); | 63 | const sources = useSourcesWithPending(scopeId); |
| 64 | const [connectOpen, setConnectOpen] = useState(false); | ||
| 65 | |||
| 66 | return ( | ||
| 67 | <div className="min-h-0 flex-1 overflow-y-auto"> | ||
| 68 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> | ||
| 69 | <div className="mb-8 flex items-end justify-between gap-4"> | ||
| 70 | <div> | ||
| 71 | <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | ||
| 72 | Sources | ||
| 73 | </h1> | ||
| 74 | <p className="mt-1.5 text-[14px] text-muted-foreground"> | ||
| 75 | Tool providers available in this workspace. | ||
| 76 | </p> | ||
| 77 | </div> | ||
| 78 | <Button onClick={() => setConnectOpen(true)} size="sm" className="shrink-0 gap-1.5"> | ||
| 79 | <PlusIcon className="size-4" /> | ||
| 80 | Connect | ||
| 81 | </Button> | ||
| 82 | </div> | ||
| 83 | |||
| 84 | {AsyncResult.match(sources, { | ||
| 85 | onInitial: () => <SourcesGridSkeleton />, | ||
| 86 | onFailure: () => <p className="text-sm text-destructive">Failed to load sources</p>, | ||
| 87 | onSuccess: ({ value }) => { | ||
| 88 | const connectedSources = ( | ||
| 89 | value as Array<{ | ||
| 90 | readonly id: string; | ||
| 91 | readonly name: string; | ||
| 92 | readonly kind: string; | ||
| 93 | readonly url?: string; | ||
| 94 | readonly runtime?: boolean; | ||
| 95 | }> | ||
| 96 | ).filter((source) => !source.runtime); | ||
| 97 | |||
| 98 | if (connectedSources.length === 0) { | ||
| 99 | return <EmptySources onConnect={() => setConnectOpen(true)} />; | ||
| 100 | } | ||
| 101 | |||
| 102 | return ( | ||
| 103 | <div className="mb-8 space-y-3"> | ||
| 104 | <SourceGrid sources={connectedSources} /> | ||
| 105 | </div> | ||
| 106 | ); | ||
| 107 | }, | ||
| 108 | })} | ||
| 109 | |||
| 110 | <div className="mb-8 border-t border-border/50" /> | ||
| 111 | |||
| 112 | <div className="mb-8"> | ||
| 113 | <McpInstallCard /> | ||
| 114 | </div> | ||
| 115 | </div> | ||
| 116 | |||
| 117 | <ConnectDialog open={connectOpen} onOpenChange={setConnectOpen} /> | ||
| 118 | </div> | ||
| 119 | ); | ||
| 120 | } | ||
| 121 | |||
| 122 | // --------------------------------------------------------------------------- | ||
| 123 | // Connect dialog — URL detection + manual plugin chooser + presets | ||
| 124 | // --------------------------------------------------------------------------- | ||
| 125 | |||
| 126 | // Heuristic: the input either looks like a URL (auto-detect) or a free-text | ||
| 127 | // search query (filter the preset list). Anything with a scheme, slash, or | ||
| 128 | // host-with-TLD is treated as a URL; everything else is search. | ||
| 129 | const looksLikeUrl = (raw: string): boolean => { | ||
| 130 | const v = raw.trim(); | ||
| 131 | if (v.length === 0) return false; | ||
| 132 | if (/^[a-z][a-z0-9+\-.]*:\/\//i.test(v)) return true; | ||
| 133 | if (v.includes("/")) return true; | ||
| 134 | if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?$/i.test(v)) return true; | ||
| 135 | return false; | ||
| 136 | }; | ||
| 137 | |||
| 138 | function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => void }) { | ||
| 139 | const sourcePlugins = useSourcePlugins(); | ||
| 140 | const scopeId = useScope(); | ||
| 63 | const doDetect = useAtomSet(detectSource, { mode: "promise" }); | 141 | const doDetect = useAtomSet(detectSource, { mode: "promise" }); |
| 64 | const navigate = useNavigate(); | 142 | const navigate = useNavigate(); |
| 65 | 143 | ||
| 144 | const [query, setQuery] = useState(""); | ||
| 145 | const [detecting, setDetecting] = useState(false); | ||
| 146 | const [error, setError] = useState<string | null>(null); | ||
| 147 | |||
| 148 | const isUrl = looksLikeUrl(query); | ||
| 149 | const presetSearch = isUrl ? "" : query; | ||
| 150 | |||
| 151 | const closeAndReset = useCallback(() => { | ||
| 152 | setQuery(""); | ||
| 153 | setError(null); | ||
| 154 | setDetecting(false); | ||
| 155 | props.onOpenChange(false); | ||
| 156 | }, [props]); | ||
| 157 | |||
| 66 | const handleDetect = useCallback(async () => { | 158 | const handleDetect = useCallback(async () => { |
| 67 | const trimmed = url.trim(); | 159 | const trimmed = query.trim(); |
| 68 | if (!trimmed) return; | 160 | if (!trimmed) return; |
| 69 | setDetecting(true); | 161 | setDetecting(true); |
| 70 | setError(null); | 162 | setError(null); |
| @@ -86,6 +178,7 @@ export function SourcesPage() { · 15 unmodified lines | |||
| 86 | } | 178 | } |
| 87 | const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind]; | 179 | const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind]; |
| 88 | if (pluginKey) { | 180 | if (pluginKey) { |
| 181 | closeAndReset(); | ||
| 89 | void navigate({ | 182 | void navigate({ |
| 90 | to: "/sources/add/$pluginKey", | 183 | to: "/sources/add/$pluginKey", |
| 91 | params: { pluginKey }, | 184 | params: { pluginKey }, |
| @@ -93,134 +186,111 @@ export function SourcesPage() { · 1 unmodified line | |||
| 93 | }); | 186 | }); |
| 94 | } else { | 187 | } else { |
| 95 | setError(`Detected source type "${detected.kind}" but no plugin is available for it.`); | 188 | setError(`Detected source type "${detected.kind}" but no plugin is available for it.`); |
| 189 | setDetecting(false); | ||
| 96 | } | 190 | } |
| 97 | } catch { | 191 | } catch { |
| 98 | setError("Detection failed. Try adding a source manually."); | 192 | setError("Detection failed. Try adding a source manually."); |
| 99 | } finally { | ||
| 100 | setDetecting(false); | 193 | setDetecting(false); |
| 101 | } | 194 | } |
| 102 | }, [url, doDetect, navigate, scopeId]); | 195 | }, [query, doDetect, navigate, scopeId, closeAndReset]); |
| 103 | 196 | ||
| 104 | return ( | 197 | return ( |
| 105 | <div className="min-h-0 flex-1 overflow-y-auto"> | 198 | <Dialog |
| 106 | <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> | 199 | open={props.open} |
| 107 | {/* Header */} | 200 | onOpenChange={(open) => { |
| 108 | <div className="mb-8"> | 201 | if (!open) closeAndReset(); |
| 109 | <div className="flex items-end justify-between"> | 202 | else props.onOpenChange(open); |
| 110 | <div> | 203 | }} |
| 111 | <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | 204 | > |
| 112 | Sources | 205 | <DialogContent className="sm:max-w-[560px]"> |
| 113 | </h1> | 206 | <DialogHeader> |
| 114 | <p className="mt-1.5 text-[14px] text-muted-foreground"> | 207 | <DialogTitle>Connect a source</DialogTitle> |
| 115 | Tool providers available in this workspace. | 208 | <DialogDescription> |
| 116 | </p> | 209 | Search the preset library, or paste a URL to auto-detect. |
| 210 | </DialogDescription> | ||
| 211 | </DialogHeader> | ||
| 212 | |||
| 213 | <div className="flex min-w-0 flex-col gap-5"> | ||
| 214 | <div className="flex flex-col gap-2"> | ||
| 215 | <div className="flex gap-2"> | ||
| 216 | <Input | ||
| 217 | type="text" | ||
| 218 | value={query} | ||
| 219 | onChange={(e) => { | ||
| 220 | setQuery((e.target as HTMLInputElement).value); | ||
| 221 | setError(null); | ||
| 222 | }} | ||
| 223 | onKeyDown={(e) => { | ||
| 224 | if (e.key === "Enter" && isUrl) void handleDetect(); | ||
| 225 | }} | ||
| 226 | placeholder="Search or paste a URL…" | ||
| 227 | disabled={detecting} | ||
| 228 | className="flex-1" | ||
| 229 | /> | ||
| 230 | {isUrl && ( | ||
| 231 | <Button | ||
| 232 | onClick={() => void handleDetect()} | ||
| 233 | disabled={detecting || !query.trim()} | ||
| 234 | > | ||
| 235 | {detecting ? "Detecting..." : "Detect"} | ||
| 236 | </Button> | ||
| 237 | )} | ||
| 117 | </div> | 238 | </div> |
| 239 | {error && <p className="text-xs text-destructive">{error}</p>} | ||
| 118 | </div> | 240 | </div> |
| 119 | 241 | ||
| 120 | {/* URL detection input */} | 242 | <div className="flex flex-col gap-2"> |
| 121 | <div className="mt-5"> | 243 | <p className="text-xs font-medium text-foreground/80">Or add manually</p> |
| 122 | <CardStack> | 244 | <div className="flex flex-wrap gap-2"> |
| 123 | <CardStackContent> | 245 | {sourcePlugins.map((p) => ( |
| 124 | <CardStackEntryField | 246 | <Link |
| 125 | label="Paste URL" | 247 | key={p.key} |
| 126 | description="auto-detect source type" | 248 | to="/sources/add/$pluginKey" |
| 127 | hint={error ?? undefined} | 249 | params={{ pluginKey: p.key }} |
| 250 | onClick={closeAndReset} | ||
| 251 | className="rounded-md border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-muted" | ||
| 128 | > | 252 | > |
| 129 | <div className="flex gap-2"> | 253 | {p.label} |
| 130 | <Input | 254 | </Link> |
| 131 | type="url" | 255 | ))} |
| 132 | value={url} | 256 | </div> |
| 133 | onChange={(e) => { | ||
| 134 | setUrl((e.target as HTMLInputElement).value); | ||
| 135 | setError(null); | ||
| 136 | }} | ||
| 137 | onKeyDown={(e) => { | ||
| 138 | if (e.key === "Enter") handleDetect(); | ||
| 139 | }} | ||
| 140 | placeholder="https://..." | ||
| 141 | disabled={detecting} | ||
| 142 | className="flex-1" | ||
| 143 | /> | ||
| 144 | <Button onClick={handleDetect} disabled={detecting || !url.trim()}> | ||
| 145 | {detecting ? "Detecting..." : "Detect"} | ||
| 146 | </Button> | ||
| 147 | </div> | ||
| 148 | <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> | ||
| 149 | Or add manually:{" "} | ||
| 150 | {sourcePlugins.map((p) => ( | ||
| 151 | <Link | ||
| 152 | key={p.key} | ||
| 153 | to="/sources/add/$pluginKey" | ||
| 154 | params={{ pluginKey: p.key }} | ||
| 155 | className="rounded-md border border-border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted" | ||
| 156 | > | ||
| 157 | {p.label} | ||
| 158 | </Link> | ||
| 159 | ))} | ||
| 160 | </div> | ||
| 161 | </CardStackEntryField> | ||
| 162 | </CardStackContent> | ||
| 163 | </CardStack> | ||
| 164 | </div> | 257 | </div> |
| 165 | </div> | ||
| 166 | 258 | ||
| 167 | <div className="mb-8"> | 259 | <PresetGrid |
| 168 | <McpInstallCard /> | 260 | plugins={sourcePlugins} |
| 261 | onPick={closeAndReset} | ||
| 262 | searchQuery={presetSearch} | ||
| 263 | /> | ||
| 169 | </div> | 264 | </div> |
| 265 | </DialogContent> | ||
| 266 | </Dialog> | ||
| 267 | ); | ||
| 268 | } | ||
| 170 | 269 | ||
| 171 | {AsyncResult.match(sources, { | 270 | // --------------------------------------------------------------------------- |
| 172 | onInitial: () => <SourcesGridSkeleton />, | 271 | // Empty state |
| 173 | onFailure: () => <p className="text-sm text-destructive">Failed to load sources</p>, | 272 | // --------------------------------------------------------------------------- |
| 174 | onSuccess: ({ value }) => { | ||
| 175 | const connectedSources = ( | ||
| 176 | value as Array<{ | ||
| 177 | readonly id: string; | ||
| 178 | readonly name: string; | ||
| 179 | readonly kind: string; | ||
| 180 | readonly url?: string; | ||
| 181 | readonly runtime?: boolean; | ||
| 182 | }> | ||
| 183 | ).filter((source) => !source.runtime); | ||
| 184 | |||
| 185 | return value.length === 0 ? ( | ||
| 186 | <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-20"> | ||
| 187 | <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground mb-4"> | ||
| 188 | <svg viewBox="0 0 24 24" fill="none" className="size-5"> | ||
| 189 | <path | ||
| 190 | d="M12 6v12M6 12h12" | ||
| 191 | stroke="currentColor" | ||
| 192 | strokeWidth="1.5" | ||
| 193 | strokeLinecap="round" | ||
| 194 | /> | ||
| 195 | </svg> | ||
| 196 | </div> | ||
| 197 | <p className="text-[14px] font-medium text-foreground/70 mb-1">No sources yet</p> | ||
| 198 | <p className="text-[13px] text-muted-foreground/60 mb-5"> | ||
| 199 | Add a source to get started. | ||
| 200 | </p> | ||
| 201 | </div> | ||
| 202 | ) : ( | ||
| 203 | <div className="mb-8 space-y-8"> | ||
| 204 | {connectedSources.length > 0 && ( | ||
| 205 | <section className="space-y-3"> | ||
| 206 | <SourceGrid sources={connectedSources} /> | ||
| 207 | </section> | ||
| 208 | )} | ||
| 209 | </div> | ||
| 210 | ); | ||
| 211 | }, | ||
| 212 | })} | ||
| 213 | |||
| 214 | <div className="mb-8 border-t border-border/50" /> | ||
| 215 | 273 | ||
| 216 | <PresetGrid /> | 274 | function EmptySources(props: { onConnect: () => void }) { |
| 275 | return ( | ||
| 276 | <div className="mb-8 flex flex-col items-center justify-center rounded-2xl border border-dashed border-border py-16"> | ||
| 277 | <div className="mb-4 flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground"> | ||
| 278 | <PlusIcon className="size-5" /> | ||
| 217 | </div> | 279 | </div> |
| 280 | <p className="mb-1 text-[14px] font-medium text-foreground/70">No sources yet</p> | ||
| 281 | <p className="mb-5 text-[13px] text-muted-foreground/60"> | ||
| 282 | Connect a source to start curating tools. | ||
| 283 | </p> | ||
| 284 | <Button onClick={props.onConnect} size="sm" className="gap-1.5"> | ||
| 285 | <PlusIcon className="size-4" /> | ||
| 286 | Connect a source | ||
| 287 | </Button> | ||
| 218 | </div> | 288 | </div> |
| 219 | ); | 289 | ); |
| 220 | } | 290 | } |
| 221 | 291 | ||
| 222 | // --------------------------------------------------------------------------- | 292 | // --------------------------------------------------------------------------- |
| 223 | // Preset grid | 293 | // Preset grid (for inside the Connect dialog) |
| 224 | // --------------------------------------------------------------------------- | 294 | // --------------------------------------------------------------------------- |
| 225 | 295 | ||
| 226 | type PresetEntry = { | 296 | type PresetEntry = { |
| @@ -229,11 +299,16 @@ type PresetEntry = { · 2 unmodified lines | |||
| 229 | pluginLabel: string; | 299 | pluginLabel: string; |
| 230 | }; | 300 | }; |
| 231 | 301 | ||
| 232 | function PresetGrid() { | 302 | function PresetGrid(props: { |
| 233 | const plugins = useSourcePlugins(); | 303 | plugins: readonly SourcePlugin[]; |
| 304 | onPick: () => void; | ||
| 305 | /** Controlled filter query forwarded from the dialog's unified | ||
| 306 | * search/URL input. Empty string disables filtering. */ | ||
| 307 | searchQuery?: string; | ||
| 308 | }) { | ||
| 234 | const allPresets = useMemo(() => { | 309 | const allPresets = useMemo(() => { |
| 235 | const entries: PresetEntry[] = []; | 310 | const entries: PresetEntry[] = []; |
| 236 | for (const plugin of plugins) { | 311 | for (const plugin of props.plugins) { |
| 237 | for (const preset of plugin.presets ?? []) { | 312 | for (const preset of plugin.presets ?? []) { |
| 238 | entries.push({ | 313 | entries.push({ |
| 239 | preset, | 314 | preset, |
| @@ -243,58 +318,81 @@ function PresetGrid() { · 3 unmodified lines | |||
| 243 | } | 318 | } |
| 244 | } | 319 | } |
| 245 | return entries; | 320 | return entries; |
| 246 | }, [plugins]); | 321 | }, [props.plugins]); |
| 322 | |||
| 323 | const filtered = useMemo(() => { | ||
| 324 | const q = (props.searchQuery ?? "").trim().toLowerCase(); | ||
| 325 | if (q.length === 0) return allPresets; | ||
| 326 | return allPresets.filter(({ preset, pluginLabel }) => { | ||
| 327 | const corpus = | ||
| 328 | `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase(); | ||
| 329 | return corpus.includes(q); | ||
| 330 | }); | ||
| 331 | }, [allPresets, props.searchQuery]); | ||
| 247 | 332 | ||
| 248 | if (allPresets.length === 0) return null; | 333 | if (allPresets.length === 0) return null; |
| 249 | 334 | ||
| 250 | return ( | 335 | return ( |
| 251 | <section className="mb-8 space-y-3"> | 336 | <div className="flex min-w-0 flex-col gap-2"> |
| 252 | <CardStack searchable> | 337 | <p className="text-xs font-medium text-foreground/80">Popular sources</p> |
| 253 | <CardStackHeader>Popular sources</CardStackHeader> | 338 | <CardStack className="min-w-0"> |
| 254 | <CardStackContent> | 339 | {/* Fixed height keeps the dialog stable as the user filters; the |
| 255 | {allPresets.map(({ preset, pluginKey, pluginLabel }) => { | 340 | * inner area scrolls when the list overflows and shows an empty |
| 256 | const search: Record<string, string> = { preset: preset.id }; | 341 | * state when no presets match. */} |
| 257 | if (preset.url) search.url = preset.url; | 342 | <CardStackContent className="h-64 overflow-y-auto"> |
| 258 | return ( | 343 | {filtered.length === 0 ? ( |
| 259 | <CardStackEntry | 344 | <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-6 text-center"> |
| 260 | key={`${pluginKey}-${preset.id}`} | 345 | <p className="text-sm text-muted-foreground">No matching presets</p> |
| 261 | asChild | 346 | <p className="text-xs text-muted-foreground/70"> |
| 262 | searchText={`${preset.name} ${preset.summary ?? ""} ${pluginLabel}`} | 347 | Paste a URL above to auto-detect, or pick a source type manually. |
| 263 | > | 348 | </p> |
| 264 | <Link to="/sources/add/$pluginKey" params={{ pluginKey }} search={search}> | 349 | </div> |
| 265 | <CardStackEntryMedia> | 350 | ) : ( |
| 266 | {preset.icon ? ( | 351 | filtered.map(({ preset, pluginKey, pluginLabel }) => { |
| 267 | <img | 352 | const search: Record<string, string> = { preset: preset.id }; |
| 268 | src={preset.icon} | 353 | if (preset.url) search.url = preset.url; |
| 269 | alt="" | 354 | return ( |
| 270 | className="size-5 object-contain" | 355 | <CardStackEntry key={`${pluginKey}-${preset.id}`} asChild> |
| 271 | loading="lazy" | 356 | <Link |
| 272 | /> | 357 | to="/sources/add/$pluginKey" |
| 273 | ) : ( | 358 | params={{ pluginKey }} |
| 274 | <svg viewBox="0 0 16 16" className="size-3.5" fill="none"> | 359 | search={search} |
| 275 | <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" /> | 360 | onClick={props.onPick} |
| 276 | </svg> | 361 | > |
| 277 | )} | 362 | <CardStackEntryMedia> |
| 278 | </CardStackEntryMedia> | 363 | {preset.icon ? ( |
| 279 | <CardStackEntryContent> | 364 | <img |
| 280 | <CardStackEntryTitle>{preset.name}</CardStackEntryTitle> | 365 | src={preset.icon} |
| 281 | <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription> | 366 | alt="" |
| 282 | </CardStackEntryContent> | 367 | className="size-5 object-contain" |
| 283 | <CardStackEntryActions> | 368 | loading="lazy" |
| 284 | <Badge variant="secondary">{pluginLabel}</Badge> | 369 | /> |
| 285 | </CardStackEntryActions> | 370 | ) : ( |
| 286 | </Link> | 371 | <svg viewBox="0 0 16 16" className="size-3.5" fill="none"> |
| 287 | </CardStackEntry> | 372 | <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" /> |
| 288 | ); | 373 | </svg> |
| 289 | })} | 374 | )} |
| 375 | </CardStackEntryMedia> | ||
| 376 | <CardStackEntryContent> | ||
| 377 | <CardStackEntryTitle>{preset.name}</CardStackEntryTitle> | ||
| 378 | <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription> | ||
| 379 | </CardStackEntryContent> | ||
| 380 | <CardStackEntryActions> | ||
| 381 | <Badge variant="secondary">{pluginLabel}</Badge> | ||
| 382 | </CardStackEntryActions> | ||
| 383 | </Link> | ||
| 384 | </CardStackEntry> | ||
| 385 | ); | ||
| 386 | }) | ||
| 387 | )} | ||
| 290 | </CardStackContent> | 388 | </CardStackContent> |
| 291 | </CardStack> | 389 | </CardStack> |
| 292 | </section> | 390 | </div> |
| 293 | ); | 391 | ); |
| 294 | } | 392 | } |
| 295 | 393 | ||
| 296 | // --------------------------------------------------------------------------- | 394 | // --------------------------------------------------------------------------- |
| 297 | // Source grid | 395 | // Source grid — flat list of connected sources, click-through to detail |
| 298 | // --------------------------------------------------------------------------- | 396 | // --------------------------------------------------------------------------- |
| 299 | 397 | ||
| 300 | function SourceGrid(props: { | 398 | function SourceGrid(props: { |
| @@ -315,7 +413,6 @@ function SourceGrid(props: { · 14 unmodified lines | |||
| 315 | 413 | ||
| 316 | return ( | 414 | return ( |
| 317 | <CardStack searchable> | 415 | <CardStack searchable> |
| 318 | <CardStackHeader>Connected</CardStackHeader> | ||
| 319 | <CardStackContent> | 416 | <CardStackContent> |
| 320 | {props.sources.map((s) => { | 417 | {props.sources.map((s) => { |
| 321 | const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind; | 418 | const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind; |