- 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 | 107 | server: { |
| 108 | 108 | port: parseInt(process.env.PORT ?? "5173", 10), |
| 109 | 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 | 122 | plugins: [ |
| 112 | 123 | tailwindcss(), |
| @@ -1,7 +1,8 @@ | ||
| 1 | −import { Suspense, useState, useCallback, useMemo } from "react"; | |
| 1 | +import { Suspense, useCallback, useMemo, useState } from "react"; | |
| 2 | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | import { useAtomSet } from "@effect/atom-react"; |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | +import { PlusIcon } from "lucide-react"; | |
| 5 | 6 | import type { SourceDetectionResult } from "@executor-js/sdk"; |
| 6 | 7 | import { |
| 7 | 8 | useSourcePlugins, |
| @@ -15,17 +16,22 @@ import { McpInstallCard } from "../components/mcp-install-card"; · 7 unmodified lines | ||
| 15 | 16 | import { Button } from "../components/button"; |
| 16 | 17 | import { Badge } from "../components/badge"; |
| 17 | 18 | import { Input } from "../components/input"; |
| 19 | +import { | |
| 20 | + Dialog, | |
| 21 | + DialogContent, | |
| 22 | + DialogDescription, | |
| 23 | + DialogHeader, | |
| 24 | + DialogTitle, | |
| 25 | +} from "../components/dialog"; | |
| 18 | 26 | import { |
| 19 | 27 | CardStack, |
| 20 | − CardStackHeader, | |
| 21 | 28 | CardStackContent, |
| 22 | 29 | CardStackEntry, |
| 23 | − CardStackEntryField, | |
| 24 | − CardStackEntryMedia, | |
| 30 | + CardStackEntryActions, | |
| 25 | 31 | CardStackEntryContent, |
| 26 | − CardStackEntryTitle, | |
| 27 | 32 | CardStackEntryDescription, |
| 28 | − CardStackEntryActions, | |
| 33 | + CardStackEntryMedia, | |
| 34 | + CardStackEntryTitle, | |
| 29 | 35 | } from "../components/card-stack"; |
| 30 | 36 | import { SourceFavicon } from "../components/source-favicon"; |
| 31 | 37 | import { Skeleton } from "../components/skeleton"; |
| @@ -53,18 +59,104 @@ const bestDetection = ( · 21 unmodified lines | ||
| 53 | 59 | // --------------------------------------------------------------------------- |
| 54 | 60 | |
| 55 | 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 | 62 | const scopeId = useScope(); |
| 62 | 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 | 141 | const doDetect = useAtomSet(detectSource, { mode: "promise" }); |
| 64 | 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 | 158 | const handleDetect = useCallback(async () => { |
| 67 | − const trimmed = url.trim(); | |
| 159 | + const trimmed = query.trim(); | |
| 68 | 160 | if (!trimmed) return; |
| 69 | 161 | setDetecting(true); |
| 70 | 162 | setError(null); |
| @@ -86,6 +178,7 @@ export function SourcesPage() { · 15 unmodified lines | ||
| 86 | 178 | } |
| 87 | 179 | const pluginKey = KIND_TO_PLUGIN_KEY[detected.kind]; |
| 88 | 180 | if (pluginKey) { |
| 181 | + closeAndReset(); | |
| 89 | 182 | void navigate({ |
| 90 | 183 | to: "/sources/add/$pluginKey", |
| 91 | 184 | params: { pluginKey }, |
| @@ -93,134 +186,111 @@ export function SourcesPage() { · 1 unmodified line | ||
| 93 | 186 | }); |
| 94 | 187 | } else { |
| 95 | 188 | setError(`Detected source type "${detected.kind}" but no plugin is available for it.`); |
| 189 | + setDetecting(false); | |
| 96 | 190 | } |
| 97 | 191 | } catch { |
| 98 | 192 | setError("Detection failed. Try adding a source manually."); |
| 99 | − } finally { | |
| 100 | 193 | setDetecting(false); |
| 101 | 194 | } |
| 102 | − }, [url, doDetect, navigate, scopeId]); | |
| 195 | + }, [query, doDetect, navigate, scopeId, closeAndReset]); | |
| 103 | 196 | |
| 104 | 197 | return ( |
| 105 | − <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 106 | − <div className="mx-auto max-w-4xl px-6 py-10 lg:px-10 lg:py-14"> | |
| 107 | − {/* Header */} | |
| 108 | − <div className="mb-8"> | |
| 109 | − <div className="flex items-end justify-between"> | |
| 110 | − <div> | |
| 111 | − <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl"> | |
| 112 | − Sources | |
| 113 | − </h1> | |
| 114 | − <p className="mt-1.5 text-[14px] text-muted-foreground"> | |
| 115 | − Tool providers available in this workspace. | |
| 116 | − </p> | |
| 198 | + <Dialog | |
| 199 | + open={props.open} | |
| 200 | + onOpenChange={(open) => { | |
| 201 | + if (!open) closeAndReset(); | |
| 202 | + else props.onOpenChange(open); | |
| 203 | + }} | |
| 204 | + > | |
| 205 | + <DialogContent className="sm:max-w-[560px]"> | |
| 206 | + <DialogHeader> | |
| 207 | + <DialogTitle>Connect a source</DialogTitle> | |
| 208 | + <DialogDescription> | |
| 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 | 238 | </div> |
| 239 | + {error && <p className="text-xs text-destructive">{error}</p>} | |
| 118 | 240 | </div> |
| 119 | 241 | |
| 120 | − {/* URL detection input */} | |
| 121 | − <div className="mt-5"> | |
| 122 | − <CardStack> | |
| 123 | − <CardStackContent> | |
| 124 | − <CardStackEntryField | |
| 125 | − label="Paste URL" | |
| 126 | − description="auto-detect source type" | |
| 127 | − hint={error ?? undefined} | |
| 242 | + <div className="flex flex-col gap-2"> | |
| 243 | + <p className="text-xs font-medium text-foreground/80">Or add manually</p> | |
| 244 | + <div className="flex flex-wrap gap-2"> | |
| 245 | + {sourcePlugins.map((p) => ( | |
| 246 | + <Link | |
| 247 | + key={p.key} | |
| 248 | + to="/sources/add/$pluginKey" | |
| 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"> | |
| 130 | − <Input | |
| 131 | − type="url" | |
| 132 | − value={url} | |
| 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> | |
| 253 | + {p.label} | |
| 254 | + </Link> | |
| 255 | + ))} | |
| 256 | + </div> | |
| 164 | 257 | </div> |
| 165 | − </div> | |
| 166 | 258 | |
| 167 | − <div className="mb-8"> | |
| 168 | − <McpInstallCard /> | |
| 259 | + <PresetGrid | |
| 260 | + plugins={sourcePlugins} | |
| 261 | + onPick={closeAndReset} | |
| 262 | + searchQuery={presetSearch} | |
| 263 | + /> | |
| 169 | 264 | </div> |
| 265 | + </DialogContent> | |
| 266 | + </Dialog> | |
| 267 | + ); | |
| 268 | +} | |
| 170 | 269 | |
| 171 | − {AsyncResult.match(sources, { | |
| 172 | − onInitial: () => <SourcesGridSkeleton />, | |
| 173 | − onFailure: () => <p className="text-sm text-destructive">Failed to load sources</p>, | |
| 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" /> | |
| 270 | +// --------------------------------------------------------------------------- | |
| 271 | +// Empty state | |
| 272 | +// --------------------------------------------------------------------------- | |
| 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 | 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 | 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 | 296 | type PresetEntry = { |
| @@ -229,11 +299,16 @@ type PresetEntry = { · 2 unmodified lines | ||
| 229 | 299 | pluginLabel: string; |
| 230 | 300 | }; |
| 231 | 301 | |
| 232 | −function PresetGrid() { | |
| 233 | − const plugins = useSourcePlugins(); | |
| 302 | +function PresetGrid(props: { | |
| 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 | 309 | const allPresets = useMemo(() => { |
| 235 | 310 | const entries: PresetEntry[] = []; |
| 236 | − for (const plugin of plugins) { | |
| 311 | + for (const plugin of props.plugins) { | |
| 237 | 312 | for (const preset of plugin.presets ?? []) { |
| 238 | 313 | entries.push({ |
| 239 | 314 | preset, |
| @@ -243,58 +318,81 @@ function PresetGrid() { · 3 unmodified lines | ||
| 243 | 318 | } |
| 244 | 319 | } |
| 245 | 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 | 333 | if (allPresets.length === 0) return null; |
| 249 | 334 | |
| 250 | 335 | return ( |
| 251 | − <section className="mb-8 space-y-3"> | |
| 252 | − <CardStack searchable> | |
| 253 | − <CardStackHeader>Popular sources</CardStackHeader> | |
| 254 | − <CardStackContent> | |
| 255 | − {allPresets.map(({ preset, pluginKey, pluginLabel }) => { | |
| 256 | − const search: Record<string, string> = { preset: preset.id }; | |
| 257 | − if (preset.url) search.url = preset.url; | |
| 258 | − return ( | |
| 259 | − <CardStackEntry | |
| 260 | − key={`${pluginKey}-${preset.id}`} | |
| 261 | − asChild | |
| 262 | − searchText={`${preset.name} ${preset.summary ?? ""} ${pluginLabel}`} | |
| 263 | − > | |
| 264 | − <Link to="/sources/add/$pluginKey" params={{ pluginKey }} search={search}> | |
| 265 | − <CardStackEntryMedia> | |
| 266 | − {preset.icon ? ( | |
| 267 | − <img | |
| 268 | − src={preset.icon} | |
| 269 | − alt="" | |
| 270 | − className="size-5 object-contain" | |
| 271 | − loading="lazy" | |
| 272 | − /> | |
| 273 | − ) : ( | |
| 274 | − <svg viewBox="0 0 16 16" className="size-3.5" fill="none"> | |
| 275 | − <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" /> | |
| 276 | − </svg> | |
| 277 | − )} | |
| 278 | − </CardStackEntryMedia> | |
| 279 | − <CardStackEntryContent> | |
| 280 | − <CardStackEntryTitle>{preset.name}</CardStackEntryTitle> | |
| 281 | − <CardStackEntryDescription>{preset.summary}</CardStackEntryDescription> | |
| 282 | − </CardStackEntryContent> | |
| 283 | − <CardStackEntryActions> | |
| 284 | − <Badge variant="secondary">{pluginLabel}</Badge> | |
| 285 | − </CardStackEntryActions> | |
| 286 | − </Link> | |
| 287 | − </CardStackEntry> | |
| 288 | − ); | |
| 289 | − })} | |
| 336 | + <div className="flex min-w-0 flex-col gap-2"> | |
| 337 | + <p className="text-xs font-medium text-foreground/80">Popular sources</p> | |
| 338 | + <CardStack className="min-w-0"> | |
| 339 | + {/* Fixed height keeps the dialog stable as the user filters; the | |
| 340 | + * inner area scrolls when the list overflows and shows an empty | |
| 341 | + * state when no presets match. */} | |
| 342 | + <CardStackContent className="h-64 overflow-y-auto"> | |
| 343 | + {filtered.length === 0 ? ( | |
| 344 | + <div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-6 text-center"> | |
| 345 | + <p className="text-sm text-muted-foreground">No matching presets</p> | |
| 346 | + <p className="text-xs text-muted-foreground/70"> | |
| 347 | + Paste a URL above to auto-detect, or pick a source type manually. | |
| 348 | + </p> | |
| 349 | + </div> | |
| 350 | + ) : ( | |
| 351 | + filtered.map(({ preset, pluginKey, pluginLabel }) => { | |
| 352 | + const search: Record<string, string> = { preset: preset.id }; | |
| 353 | + if (preset.url) search.url = preset.url; | |
| 354 | + return ( | |
| 355 | + <CardStackEntry key={`${pluginKey}-${preset.id}`} asChild> | |
| 356 | + <Link | |
| 357 | + to="/sources/add/$pluginKey" | |
| 358 | + params={{ pluginKey }} | |
| 359 | + search={search} | |
| 360 | + onClick={props.onPick} | |
| 361 | + > | |
| 362 | + <CardStackEntryMedia> | |
| 363 | + {preset.icon ? ( | |
| 364 | + <img | |
| 365 | + src={preset.icon} | |
| 366 | + alt="" | |
| 367 | + className="size-5 object-contain" | |
| 368 | + loading="lazy" | |
| 369 | + /> | |
| 370 | + ) : ( | |
| 371 | + <svg viewBox="0 0 16 16" className="size-3.5" fill="none"> | |
| 372 | + <circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.2" /> | |
| 373 | + </svg> | |
| 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 | 388 | </CardStackContent> |
| 291 | 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 | 398 | function SourceGrid(props: { |
| @@ -315,7 +413,6 @@ function SourceGrid(props: { · 14 unmodified lines | ||
| 315 | 413 | |
| 316 | 414 | return ( |
| 317 | 415 | <CardStack searchable> |
| 318 | − <CardStackHeader>Connected</CardStackHeader> | |
| 319 | 416 | <CardStackContent> |
| 320 | 417 | {props.sources.map((s) => { |
| 321 | 418 | const pluginKey = KIND_TO_PLUGIN_KEY[s.kind] ?? s.kind; |