Files
wehub-resource-sync adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:17:32 +08:00

1714 lines
40 KiB
Svelte

<script lang="ts">
import {
LIBRARY,
SPACE_CATEGORIES,
TASK_SCHEMAS,
categorizeSpace,
type NodeTemplate
} from "./node-library";
import { PORT_COLOR } from "./workflow-types";
import type { PortType } from "./workflow-types";
import { fetchSpaceApi, normalize_space_id } from "./space-api";
import ChevronLeftIcon from "./icons/ChevronLeftIcon.svelte";
import ChevronDownIcon from "./icons/ChevronDownIcon.svelte";
interface TrendingSpace {
id: string;
title: string;
description: string;
likes: number;
running: boolean;
category: string | null;
}
let {
onadd = undefined,
server = {},
hfToken = "",
trendingSpaces: propTrendingSpaces = undefined,
trendingLoading: propTrendingLoading = undefined
}: {
onadd?: (template: NodeTemplate) => void;
server?: Record<string, any>;
hfToken?: string;
trendingSpaces?: TrendingSpace[];
trendingLoading?: boolean;
} = $props();
let expandedSection: string | null = $state("spaces");
let collapsed = $state(false);
let sidebarWidth = $state(240);
let isResizing = $state(false);
// Load custom spaces from localStorage
let customSpaces: NodeTemplate[] = $state(
typeof localStorage !== "undefined"
? JSON.parse(localStorage.getItem("gradio_custom_spaces") ?? "[]")
: []
);
// Auto-save custom spaces to localStorage
$effect(() => {
if (typeof localStorage !== "undefined") {
localStorage.setItem(
"gradio_custom_spaces",
JSON.stringify(customSpaces)
);
}
});
function startResize(e: MouseEvent) {
e.preventDefault();
isResizing = true;
const startX = e.clientX;
const startWidth = sidebarWidth;
function onMove(ev: MouseEvent) {
sidebarWidth = Math.max(
160,
Math.min(400, startWidth + (ev.clientX - startX))
);
}
function onUp() {
isResizing = false;
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
}
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}
let customSpaceInput = $state("");
let loadingSpace = $state(false);
let loadingSpaceName = $state("");
let spaceError = $state("");
function handleWindowClick(e: MouseEvent): void {
const target = e.target as HTMLElement;
if (searchResults.length > 0 && !target.closest(".add-space-wrapper")) {
searchResults = [];
}
}
// Trending spaces — use parent-provided data if available, otherwise fetch own
let ownTrendingSpaces: TrendingSpace[] = $state([]);
let ownTrendingLoading = $state(true);
let trendingSpaces = $derived(propTrendingSpaces ?? ownTrendingSpaces);
let trendingLoading = $derived(propTrendingLoading ?? ownTrendingLoading);
async function fetchTrendingSpaces() {
if (propTrendingSpaces !== undefined) return;
if (!server?.search_spaces) {
ownTrendingLoading = false;
return;
}
try {
const raw = await server.search_spaces(["trending", "", ""]);
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!Array.isArray(data)) {
ownTrendingSpaces = [];
return;
}
ownTrendingSpaces = data
.map((s: any) => {
const desc = s.cardData?.short_description || "";
return {
id: s.id,
title: s.cardData?.title || s.id.split("/").pop() || s.id,
description: desc,
likes: s.likes ?? 0,
running: s.runtime?.stage === "RUNNING",
category: categorizeSpace(
s.cardData?.pipeline_tag || s.pipeline_tag,
s.cardData?.tags,
desc,
s.id
)
};
})
.filter((s: TrendingSpace) => s.category !== null && s.running);
} catch {
ownTrendingSpaces = [];
} finally {
ownTrendingLoading = false;
}
}
if (typeof window !== "undefined") {
fetchTrendingSpaces();
}
function trendingToTemplate(space: TrendingSpace): NodeTemplate {
return {
label: space.id.split("/").pop() || space.id,
kind: "transform",
source: "space",
space_id: space.id,
category: space.category ?? undefined,
inputs: [],
outputs: [],
width: 280,
height: 90
};
}
function getTrendingByCategory(cat: string): TrendingSpace[] {
return trendingSpaces.filter((s) => s.category === cat);
}
// Search state
let searchResults: {
id: string;
likes: number;
title?: string;
description?: string;
running: boolean;
}[] = $state([]);
let searching = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
function handleSearchInput(e: Event) {
const query = (e.currentTarget as HTMLInputElement).value;
customSpaceInput = query;
spaceError = "";
if (searchTimeout) clearTimeout(searchTimeout);
if (query.length < 2) {
searchResults = [];
searching = false;
return;
}
if (normalize_space_id(query)) {
searchResults = [];
return;
}
searching = true;
searchTimeout = setTimeout(async () => {
try {
if (!server?.search_spaces) {
searchResults = [];
return;
}
const raw = await server.search_spaces(["search", query, ""]);
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!Array.isArray(data)) {
searchResults = [];
return;
}
searchResults = data.slice(0, 6).map((s: any) => ({
id: s.id,
likes: s.likes ?? 0,
title: s.title || s.id.split("/").pop(),
description: s.ai_short_description || "",
running: s.runtime?.stage === "RUNNING"
}));
} catch {
searchResults = [];
} finally {
searching = false;
}
}, 300);
}
function selectSearchResult(spaceId: string) {
customSpaceInput = spaceId;
searchResults = [];
addCustomSpace();
}
function primaryType(item: (typeof LIBRARY)["inputs"][number]): PortType {
return item.outputs[0]?.type ?? item.inputs[0]?.type ?? "any";
}
function toggle(section: string) {
expandedSection = expandedSection === section ? null : section;
}
async function addCustomSpace() {
const spaceId = normalize_space_id(customSpaceInput);
if (!spaceId) {
spaceError = "Enter owner/repo or paste a Space URL";
return;
}
if (customSpaces.some((s) => s.space_id === spaceId)) {
customSpaceInput = "";
return;
}
loadingSpace = true;
loadingSpaceName = spaceId;
spaceError = "";
customSpaceInput = "";
try {
const apiInfo = await fetchSpaceApi(spaceId);
const label = spaceId.split("/").pop() ?? spaceId;
customSpaces = [
...customSpaces,
{
label,
kind: "transform",
source: "space",
space_id: spaceId,
endpoint: apiInfo.endpoint,
inputs: apiInfo.inputs,
outputs: apiInfo.outputs,
width: apiInfo.width,
height: 90
}
];
customSpaceInput = "";
} catch (err) {
spaceError = err instanceof Error ? err.message : "Failed to connect";
} finally {
loadingSpace = false;
}
}
// ── Model search ──
interface HFModel {
id: string;
pipeline_tag: string;
likes: number;
downloads: number;
}
const MODEL_TASKS = [
{ key: "text-to-image", label: "Text → Image" },
{ key: "text-generation", label: "Text Generation" },
{ key: "image-to-text", label: "Image → Text" },
{ key: "image-classification", label: "Image Classification" },
{ key: "object-detection", label: "Object Detection" },
{ key: "automatic-speech-recognition", label: "Speech → Text" },
{ key: "text-to-speech", label: "Text → Speech" },
{ key: "translation", label: "Translation" },
{ key: "summarization", label: "Summarization" },
{ key: "text-classification", label: "Text Classification" },
{ key: "image-to-image", label: "Image → Image" },
{ key: "depth-estimation", label: "Depth Estimation" },
{ key: "image-segmentation", label: "Image Segmentation" },
{ key: "text-to-video", label: "Text → Video" },
{ key: "image-text-to-text", label: "Image+Text → Text" },
{ key: "feature-extraction", label: "Embeddings" }
];
let modelSearchQuery = $state("");
let modelSearchResults: HFModel[] = $state([]);
let modelSearching = $state(false);
let modelSearchTimeout: ReturnType<typeof setTimeout> | null = null;
let selectedModelTask = $state("");
let trendingModels: HFModel[] = $state([]);
let trendingModelsLoading = $state(true);
async function fetchTrendingModels() {
if (!server?.search_models) {
trendingModelsLoading = false;
return;
}
try {
const raw = await server.search_models(["trending", "", "", ""]);
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!Array.isArray(data)) {
trendingModels = [];
return;
}
trendingModels = data
.filter((m: any) => m.pipeline_tag && TASK_SCHEMAS[m.pipeline_tag])
.map((m: any) => ({
id: m.id,
pipeline_tag: m.pipeline_tag,
likes: m.likes ?? 0,
downloads: m.downloads ?? 0
}));
} catch {
trendingModels = [];
} finally {
trendingModelsLoading = false;
}
}
if (typeof window !== "undefined") {
fetchTrendingModels();
}
function handleModelSearch(e: Event) {
const query = (e.currentTarget as HTMLInputElement).value;
modelSearchQuery = query;
if (modelSearchTimeout) clearTimeout(modelSearchTimeout);
if (query.length < 2 && !selectedModelTask) {
modelSearchResults = [];
modelSearching = false;
return;
}
modelSearching = true;
modelSearchTimeout = setTimeout(async () => {
try {
if (!server?.search_models) {
modelSearchResults = [];
return;
}
const raw = await server.search_models([
"search",
query.length >= 2 ? query : "",
selectedModelTask,
""
]);
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!Array.isArray(data)) {
modelSearchResults = [];
return;
}
modelSearchResults = data
.filter((m: any) => m.pipeline_tag && TASK_SCHEMAS[m.pipeline_tag])
.map((m: any) => ({
id: m.id,
pipeline_tag: m.pipeline_tag,
likes: m.likes ?? 0,
downloads: m.downloads ?? 0
}));
} catch {
modelSearchResults = [];
} finally {
modelSearching = false;
}
}, 300);
}
function handleTaskFilter(taskKey: string) {
selectedModelTask = selectedModelTask === taskKey ? "" : taskKey;
// Re-trigger search with new filter
handleModelSearch({ currentTarget: { value: modelSearchQuery } } as any);
}
function modelToTemplate(model: HFModel): NodeTemplate {
const schema = TASK_SCHEMAS[model.pipeline_tag];
return {
label: model.id.split("/").pop() || model.id,
kind: "transform",
source: "model",
model_id: model.id,
pipeline_tag: model.pipeline_tag,
inputs: schema?.inputs ?? [],
outputs: schema?.outputs ?? [],
width: 280,
height: 90
};
}
function getModelsForTask(task: string): HFModel[] {
return trendingModels.filter((m) => m.pipeline_tag === task);
}
// ── Dataset search ──
interface HFDataset {
id: string;
likes: number;
downloads: number;
description: string;
}
let datasetSearchQuery = $state("");
let datasetSearchResults: HFDataset[] = $state([]);
let datasetSearching = $state(false);
let datasetSearchTimeout: ReturnType<typeof setTimeout> | null = null;
let loadingDataset = $state(false);
let loadingDatasetName = $state("");
let datasetError = $state("");
let datasetsAdded = $state(0);
function handleDatasetSearch(e: Event) {
const query = (e.currentTarget as HTMLInputElement).value;
datasetSearchQuery = query;
datasetError = "";
if (datasetSearchTimeout) clearTimeout(datasetSearchTimeout);
if (query.length < 2) {
datasetSearchResults = [];
datasetSearching = false;
return;
}
datasetSearching = true;
datasetSearchTimeout = setTimeout(async () => {
try {
if (!server?.search_datasets) {
datasetSearchResults = [];
return;
}
const raw = await server.search_datasets([query, ""]);
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!Array.isArray(data)) {
datasetSearchResults = [];
return;
}
datasetSearchResults = data.map((d: any) => ({
id: d.id,
likes: d.likes ?? 0,
downloads: d.downloads ?? 0,
description: d.cardData?.description?.slice(0, 80) || ""
}));
} catch {
datasetSearchResults = [];
} finally {
datasetSearching = false;
}
}, 300);
}
async function addDataset(datasetId: string) {
loadingDataset = true;
loadingDatasetName = datasetId;
datasetError = "";
try {
if (!server?.get_dataset_schema)
throw new Error("Dataset schema function not available");
const raw = await server.get_dataset_schema([datasetId, hfToken || ""]);
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
if (data?.error) throw new Error(data.error);
const features: { name: string; type: any }[] = data.features ?? [];
const featureToPortType = (f: any): PortType => {
const dtype = f.type?.dtype || f.type?._type || "";
if (
dtype === "string" ||
(f.type?._type === "Value" && f.type?.dtype === "string")
)
return "text";
if (
dtype === "float32" ||
dtype === "float64" ||
dtype === "int32" ||
dtype === "int64"
)
return "number";
if (f.type?._type === "Image") return "image";
if (f.type?._type === "Audio") return "audio";
return "json";
};
const outputs = features.slice(0, 6).map((f, i) => ({
id: `out_${i}`,
label: f.name,
type: featureToPortType(f)
}));
const template: NodeTemplate = {
label: datasetId.split("/").pop() || datasetId,
kind: "input" as const,
source: "dataset",
dataset_id: datasetId,
dataset_config: data.config,
dataset_split: data.split,
inputs: [],
outputs,
width: 280,
height: Math.max(90, 50 + outputs.length * 22)
};
onadd?.(template);
datasetsAdded += 1;
} catch (err) {
datasetError =
err instanceof Error ? err.message : "Failed to load dataset";
} finally {
loadingDataset = false;
}
}
const sections = [
{ key: "spaces", label: "Spaces", icon: "hub", items: LIBRARY.spaces },
{
key: "models",
label: "Models",
icon: "model",
items: [] as NodeTemplate[]
},
{
key: "datasets",
label: "Datasets",
icon: "data",
items: [] as NodeTemplate[]
},
{
key: "components",
label: "Components",
icon: "layers",
items: LIBRARY.components
}
];
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<svelte:window onclick={handleWindowClick} />
<aside
class="sidebar"
class:sidebar-collapsed={collapsed}
style="width: {collapsed ? 40 : sidebarWidth}px; min-width: {collapsed
? 40
: sidebarWidth}px;"
>
<div class="sidebar-header">
<button
class="sidebar-collapse-btn"
class:collapsed
onclick={() => (collapsed = !collapsed)}
>
<ChevronLeftIcon />
</button>
</div>
{#if !collapsed}
{#each sections as section}
<button
class="section-toggle"
class:expanded={expandedSection === section.key}
onclick={() => toggle(section.key)}
>
<span class="section-label">{section.label}</span>
<span class="section-count"
>{section.key === "spaces"
? customSpaces.length + trendingSpaces.length
: section.key === "models"
? trendingModels.length
: section.key === "datasets"
? datasetsAdded
: section.items.length}</span
>
<span
class="section-chevron"
class:expanded={expandedSection === section.key}
>
<ChevronDownIcon />
</span>
</button>
{#if expandedSection === section.key}
<div class="section-items">
{#if section.key === "spaces"}
{#if trendingLoading}
<div class="trending-status">
<span class="space-loading-spinner"></span>
<span class="trending-status-text"
>Loading trending spaces...</span
>
</div>
{/if}
{#each SPACE_CATEGORIES as cat}
{@const trending = getTrendingByCategory(cat.key)}
{#if trending.length > 0}
<div class="category-header">
<span class="category-label">{cat.label}</span>
<span class="category-count">{trending.length}</span>
</div>
{#each trending as space}
<div
draggable="true"
class="space-card"
onclick={() => onadd?.(trendingToTemplate(space))}
ondragstart={(e) => {
const template = trendingToTemplate(space);
e.dataTransfer!.setData(
"node-template",
JSON.stringify(template)
);
const ghost = document.createElement("div");
ghost.textContent = space.title;
ghost.style.cssText =
"background:#f97316;color:#0c0d10;padding:4px 10px;border-radius:5px;font-family:Manrope,sans-serif;font-size:11px;font-weight:600;position:fixed;top:-100px";
document.body.appendChild(ghost);
e.dataTransfer!.setDragImage(ghost, 0, 0);
setTimeout(() => document.body.removeChild(ghost), 0);
}}
>
<div class="space-card-header">
<span
class="space-card-status"
class:space-card-running={space.running}
title={space.running ? "Running" : "Sleeping"}
></span>
<span class="space-card-name">{space.title}</span>
<span class="space-card-likes"
>&hearts; {space.likes}</span
>
<a
class="space-card-link"
href="https://huggingface.co/spaces/{space.id}"
target="_blank"
rel="noopener"
onclick={(e) => e.stopPropagation()}
title="Open on HuggingFace">&#x2197;</a
>
</div>
<div class="space-card-id">{space.id}</div>
{#if space.description}
<div class="space-card-desc">{space.description}</div>
{/if}
</div>
{/each}
{/if}
{/each}
{:else if section.key === "models"}
<div class="add-space-wrapper">
<div class="add-space">
<input
class="space-input"
type="text"
placeholder="Search models..."
value={modelSearchQuery}
oninput={handleModelSearch}
/>
</div>
</div>
<div class="model-task-filters">
{#each MODEL_TASKS as task}
<button
class="model-task-tag"
class:model-task-tag-active={selectedModelTask === task.key}
onclick={() => handleTaskFilter(task.key)}
>{task.label}</button
>
{/each}
</div>
{#if modelSearching}
<div class="space-loading">
<span class="space-loading-spinner"></span>
<span class="space-loading-text">Searching...</span>
</div>
{:else if modelSearchQuery.length >= 2 || selectedModelTask}
{#each modelSearchResults as model}
{@const template = modelToTemplate(model)}
{@const pt =
template.outputs[0]?.type ??
template.inputs[0]?.type ??
"any"}
<div
draggable="true"
class="space-card"
onclick={() => onadd?.(template)}
ondragstart={(e) => {
e.dataTransfer!.setData(
"node-template",
JSON.stringify(template)
);
const ghost = document.createElement("div");
ghost.textContent = model.id;
ghost.style.cssText = `background:${PORT_COLOR[pt]};color:#0c0d10;padding:4px 10px;border-radius:5px;font-family:Manrope,sans-serif;font-size:11px;font-weight:600;position:fixed;top:-100px`;
document.body.appendChild(ghost);
e.dataTransfer!.setDragImage(ghost, 0, 0);
setTimeout(() => document.body.removeChild(ghost), 0);
}}
>
<div class="space-card-header">
<span
class="chip-dot"
style="background: {PORT_COLOR[
pt
]}; box-shadow: 0 0 6px {PORT_COLOR[pt]}40"
></span>
<span class="space-card-name"
>{model.id.split("/").pop()}</span
>
<span class="space-card-likes">&hearts; {model.likes}</span>
</div>
<div class="space-card-id">{model.id}</div>
<div class="space-card-desc">{model.pipeline_tag}</div>
</div>
{/each}
{#if modelSearchResults.length === 0}
<div class="trending-status">
<span class="trending-status-text">No models found</span>
</div>
{/if}
{:else if trendingModelsLoading}
<div class="trending-status">
<span class="space-loading-spinner"></span>
<span class="trending-status-text"
>Loading trending models...</span
>
</div>
{:else}
{#each MODEL_TASKS.slice(0, 6) as task}
{@const models = getModelsForTask(task.key)}
{#if models.length > 0}
<div class="category-header">
<span class="category-label">{task.label}</span>
<span class="category-count">{models.length}</span>
</div>
{#each models.slice(0, 3) as model}
{@const template = modelToTemplate(model)}
{@const pt =
template.outputs[0]?.type ??
template.inputs[0]?.type ??
"any"}
<div
draggable="true"
class="space-card"
onclick={() => onadd?.(template)}
ondragstart={(e) => {
e.dataTransfer!.setData(
"node-template",
JSON.stringify(template)
);
}}
>
<div class="space-card-header">
<span
class="chip-dot"
style="background: {PORT_COLOR[
pt
]}; box-shadow: 0 0 6px {PORT_COLOR[pt]}40"
></span>
<span class="space-card-name"
>{model.id.split("/").pop()}</span
>
<span class="space-card-likes"
>&hearts; {model.likes}</span
>
</div>
<div class="space-card-id">{model.id}</div>
</div>
{/each}
{/if}
{/each}
{/if}
{:else if section.key === "datasets"}
<div class="add-space-wrapper">
<div class="add-space">
<input
class="space-input"
type="text"
placeholder="Search datasets..."
value={datasetSearchQuery}
disabled={loadingDataset}
oninput={handleDatasetSearch}
onkeydown={(e) => {
if (e.key === "Enter" && datasetSearchQuery.includes("/")) {
datasetSearchResults = [];
addDataset(datasetSearchQuery.trim());
}
if (e.key === "Escape") datasetSearchResults = [];
}}
/>
</div>
{#if datasetSearchResults.length > 0}
<div class="search-dropdown">
{#each datasetSearchResults as result}
<button
class="search-result"
onclick={() => {
const id = result.id;
datasetSearchResults = [];
datasetSearchQuery = "";
addDataset(id);
}}
>
<div class="search-result-top">
<span class="search-result-name">{result.id}</span>
<span class="search-result-likes"
>&hearts; {result.likes}</span
>
</div>
{#if result.description}
<div class="search-result-desc">
{result.description}
</div>
{/if}
</button>
{/each}
</div>
{:else if datasetSearching}
<div class="search-dropdown">
<div class="search-searching">Searching...</div>
</div>
{/if}
</div>
{#if loadingDataset}
<div class="space-loading">
<span class="space-loading-spinner"></span>
<span class="space-loading-text"
>Loading {loadingDatasetName}...</span
>
</div>
{/if}
{#if datasetError}
<span class="space-error">{datasetError}</span>
{/if}
<div class="trending-status">
<span class="trending-status-text"
>Search for a dataset by name</span
>
</div>
{:else}
{#each section.items as item}
{@const pt = primaryType(item)}
<div
draggable="true"
class="chip"
onclick={() => onadd?.(item)}
ondragstart={(e) => {
e.dataTransfer!.setData(
"node-template",
JSON.stringify(item)
);
const ghost = document.createElement("div");
ghost.textContent = item.label;
ghost.style.cssText = `background:${PORT_COLOR[pt]};color:#0c0d10;padding:4px 10px;border-radius:5px;font-family:Manrope,sans-serif;font-size:11px;font-weight:600;position:fixed;top:-100px`;
document.body.appendChild(ghost);
e.dataTransfer!.setDragImage(ghost, 0, 0);
setTimeout(() => document.body.removeChild(ghost), 0);
}}
>
<span
class="chip-dot"
style="background: {PORT_COLOR[
pt
]}; box-shadow: 0 0 6px {PORT_COLOR[pt]}40"
></span>
<span class="chip-label">{item.label}</span>
</div>
{/each}
{/if}
{#if section.key === "spaces"}
{#each customSpaces as item, i}
{@const pt = primaryType(item)}
<div
draggable="true"
class="chip"
onclick={() => onadd?.(item)}
ondragstart={(e) =>
e.dataTransfer!.setData(
"node-template",
JSON.stringify(item)
)}
>
<span
class="chip-dot"
style="background: {PORT_COLOR[
pt
]}; box-shadow: 0 0 6px {PORT_COLOR[pt]}40"
></span>
<span class="chip-label">{item.label}</span>
{#if item.space_id}
<a
class="chip-link"
href="https://huggingface.co/spaces/{item.space_id}"
target="_blank"
rel="noopener"
onclick={(e) => e.stopPropagation()}
title="Open on HuggingFace">&#x2197;</a
>
{/if}
<button
class="chip-remove"
onclick={(e) => {
e.stopPropagation();
customSpaces = customSpaces.filter((_, j) => j !== i);
}}
title="Remove">&times;</button
>
</div>
{/each}
<div class="add-space-wrapper">
<div class="add-space">
<input
class="space-input"
type="text"
placeholder="Search Spaces..."
value={customSpaceInput}
disabled={loadingSpace}
oninput={handleSearchInput}
onkeydown={(e) => {
if (e.key === "Enter" && customSpaceInput.includes("/")) {
searchResults = [];
addCustomSpace();
}
if (e.key === "Escape") searchResults = [];
}}
/>
<button
class="space-add-btn"
onclick={() => {
searchResults = [];
addCustomSpace();
}}
disabled={loadingSpace ||
!customSpaceInput.trim().includes("/")}
>{loadingSpace ? "..." : "+"}</button
>
</div>
{#if searchResults.length > 0}
<div class="search-dropdown">
{#each searchResults as result}
<button
class="search-result"
onclick={() => selectSearchResult(result.id)}
>
<div class="search-result-top">
<span
class="search-result-status"
class:search-result-running={result.running}
title={result.running ? "Running" : "Not running"}
></span>
<span class="search-result-name">{result.id}</span>
<span class="search-result-likes"
>&hearts; {result.likes}</span
>
<a
class="search-result-link"
href="https://huggingface.co/spaces/{result.id}"
target="_blank"
rel="noopener"
onclick={(e) => e.stopPropagation()}
title="Open on HuggingFace">&#x2197;</a
>
</div>
{#if result.description}
<div class="search-result-desc">
{result.description}
</div>
{/if}
</button>
{/each}
</div>
{:else if searching}
<div class="search-dropdown">
<div class="search-searching">Searching...</div>
</div>
{/if}
</div>
{#if loadingSpace}
<div class="space-loading">
<span class="space-loading-spinner"></span>
<span class="space-loading-text"
>Connecting to {loadingSpaceName}...</span
>
</div>
{/if}
{#if spaceError}
<span class="space-error">{spaceError}</span>
{/if}
{/if}
</div>
{/if}
{/each}
<div class="sidebar-footer">
<span class="hint">Drag to canvas</span>
</div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#if !collapsed}
<div class="resize-handle" onmousedown={startResize}></div>
{/if}
</aside>
<style>
.sidebar {
position: relative;
display: flex;
flex-direction: column;
background: #101118;
border-right: 1px solid #1e1f2a;
overflow-y: auto;
font-family: "Manrope", sans-serif;
color-scheme: dark;
}
.sidebar-collapsed {
width: 40px;
min-width: 40px;
}
.sidebar-collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: #5c5e6a;
cursor: pointer;
flex-shrink: 0;
padding: 0;
line-height: 1;
transition:
color 0.15s,
background 0.15s,
transform 0.2s ease;
}
.sidebar-collapse-btn:hover {
background: #1e1f2a;
color: #a0a2ae;
}
.sidebar-collapse-btn.collapsed {
transform: rotate(180deg);
}
.sidebar-header {
display: flex;
align-items: center;
gap: 6px;
padding: 12px 12px 10px;
border-bottom: 1px solid #1e1f2a;
}
.sidebar-collapsed .sidebar-header {
justify-content: center;
padding: 14px 8px 10px;
}
.section-toggle {
display: flex;
align-items: center;
width: 100%;
padding: 10px 16px;
border: none;
background: transparent;
cursor: pointer;
transition: background 0.15s;
}
.section-toggle:hover {
background: #16171f;
}
.section-label {
font-size: 12px;
font-weight: 600;
color: #a0a2ae;
flex: 1;
text-align: left;
}
.section-count {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
color: #3e3f4d;
margin-right: 6px;
}
.section-chevron {
display: inline-flex;
color: #5c5e6a;
flex-shrink: 0;
transition: transform 0.2s ease;
transform: rotate(-90deg);
}
.section-chevron.expanded {
transform: rotate(0deg);
}
.section-items {
padding: 2px 8px 8px;
}
.chip {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
margin-bottom: 2px;
border-radius: 6px;
cursor: pointer;
transition:
background 0.15s,
transform 0.1s;
background: transparent;
}
.chip:hover {
background: #1a1b25;
}
.chip:active {
cursor: grabbing;
transform: scale(0.98);
}
.chip-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.category-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px 4px;
margin-top: 4px;
}
.category-header:first-child {
margin-top: 0;
}
.category-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #5c5e6a;
}
.category-count {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
color: #3e3f4d;
}
/* Trending space cards */
.space-card {
display: flex;
flex-direction: column;
padding: 8px 10px;
margin-bottom: 2px;
border-radius: 6px;
cursor: pointer;
transition:
background 0.15s,
transform 0.1s;
background: transparent;
}
.space-card:hover {
background: #1a1b25;
}
.space-card:active {
cursor: grabbing;
transform: scale(0.98);
}
.space-card-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.space-card-status {
width: 6px;
height: 6px;
border-radius: 50%;
background: #5c5e6a;
flex-shrink: 0;
}
.space-card-running {
background: #34d399;
box-shadow: 0 0 4px #34d39960;
}
.space-card-name {
font-size: 12px;
font-weight: 600;
color: #c8c9d2;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.space-card-likes {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
color: #5c5e6a;
flex-shrink: 0;
}
.space-card-link {
visibility: hidden;
font-size: 11px;
color: #5c5e6a;
text-decoration: none;
flex-shrink: 0;
padding: 2px;
border-radius: 3px;
transition: color 0.15s;
}
.space-card:hover .space-card-link {
visibility: visible;
}
.space-card-link:hover {
color: #c8c9d2;
}
.space-card-id {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
color: #3e3f4d;
padding-left: 12px;
margin-top: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.space-card-desc {
font-size: 10px;
color: #5c5e6a;
padding-left: 12px;
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
}
.trending-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
.trending-status-text {
font-family: "JetBrains Mono", monospace;
font-size: 9.5px;
color: #5c5e6a;
}
.chip-label {
font-size: 12.5px;
font-weight: 500;
color: #c8c9d2;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip-link {
visibility: hidden;
font-size: 11px;
color: #5c5e6a;
text-decoration: none;
flex-shrink: 0;
line-height: 1;
padding: 2px;
border-radius: 3px;
transition: color 0.15s;
}
.chip:hover .chip-link {
visibility: visible;
}
.chip-link:hover {
color: #c8c9d2;
}
.chip-remove {
visibility: hidden;
display: flex;
width: 16px;
height: 16px;
border: none;
border-radius: 3px;
background: transparent;
color: #5c5e6a;
font-size: 12px;
line-height: 1;
cursor: pointer;
align-items: center;
justify-content: center;
padding: 0;
flex-shrink: 0;
}
.chip:hover .chip-remove {
visibility: visible;
}
.chip-remove:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.15);
}
.add-space {
display: flex;
gap: 4px;
padding: 6px 4px 2px;
}
.space-input {
flex: 1;
font-family: "JetBrains Mono", monospace;
font-size: 10.5px;
height: 28px;
padding: 0 8px;
border: 1px solid #1e1f2a;
border-radius: 5px;
background: #0c0d10;
color: #c8c9d2;
outline: none;
min-width: 0;
box-sizing: border-box;
}
.space-input::placeholder {
color: #5c5e6a;
}
.space-input:focus {
border-color: #3e3f4d;
}
.space-add-btn {
width: 28px;
height: 28px;
border: 1px solid #3e3f4d;
border-radius: 5px;
background: #1a1b25;
color: #a0a2ae;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
flex-shrink: 0;
}
.space-add-btn:hover:not(:disabled) {
background: #2a2b36;
color: #f97316;
border-color: #f97316;
}
.space-add-btn:disabled {
opacity: 0.35;
cursor: default;
background: #101118;
color: #3e3f4d;
border-color: #1e1f2a;
}
.add-space-wrapper {
position: relative;
}
.search-dropdown {
position: absolute;
top: 100%;
left: 4px;
right: 4px;
background: #16171f;
border: 1px solid #2a2b36;
border-radius: 6px;
margin-top: 2px;
overflow: hidden;
z-index: 10;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.search-result {
display: flex;
flex-direction: column;
width: 100%;
padding: 7px 10px;
border: none;
border-bottom: 1px solid #1e1f2a;
background: transparent;
color: #c8c9d2;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.search-result:last-child {
border-bottom: none;
}
.search-result:hover {
background: #1a1b25;
}
.search-result-top {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.search-result-status {
width: 6px;
height: 6px;
border-radius: 50%;
background: #5c5e6a;
flex-shrink: 0;
}
.search-result-running {
background: #34d399;
box-shadow: 0 0 4px #34d39960;
}
.search-result-name {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-result-desc {
font-size: 9.5px;
color: #5c5e6a;
margin-top: 2px;
padding-left: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-result-likes {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
color: #5c5e6a;
flex-shrink: 0;
}
.search-result-link {
font-size: 11px;
color: #5c5e6a;
text-decoration: none;
flex-shrink: 0;
padding: 2px;
border-radius: 3px;
transition: color 0.15s;
}
.search-result-link:hover {
color: #c8c9d2;
}
.search-searching {
font-family: "JetBrains Mono", monospace;
font-size: 9.5px;
color: #5c5e6a;
padding: 8px 10px;
text-align: center;
}
.space-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
}
.space-loading-spinner {
width: 12px;
height: 12px;
border: 2px solid transparent;
border-top-color: #f5a623;
border-radius: 50%;
animation: sidebar-spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes sidebar-spin {
to {
transform: rotate(360deg);
}
}
.space-loading-text {
font-family: "JetBrains Mono", monospace;
font-size: 9.5px;
color: #5c5e6a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.space-error {
display: block;
font-family: "JetBrains Mono", monospace;
font-size: 9px;
color: #ef4444;
padding: 2px 12px 4px;
}
.sidebar-footer {
margin-top: auto;
padding: 12px 16px;
border-top: 1px solid #1e1f2a;
}
.resize-handle {
position: absolute;
top: 0;
right: -3px;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 10;
}
.resize-handle:hover {
background: rgba(249, 115, 22, 0.3);
}
.model-task-filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 4px 4px 8px;
}
.model-task-tag {
font-family: "JetBrains Mono", monospace;
font-size: 9px;
padding: 3px 7px;
border: 1px solid #1e1f2a;
border-radius: 4px;
background: transparent;
color: #5c5e6a;
cursor: pointer;
transition: all 0.15s;
}
.model-task-tag:hover {
border-color: #3e3f4d;
color: #a0a2ae;
}
.model-task-tag-active {
border-color: #f97316;
color: #f97316;
background: rgba(249, 115, 22, 0.1);
}
.hint {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
color: #2e2f3d;
letter-spacing: 0.02em;
}
/* ─── Light mode ─── */
:global(body:not(.dark)) .sidebar {
background: #f8f9fb;
border-right-color: #e2e4ea;
color-scheme: light;
}
:global(body:not(.dark)) .sidebar-header {
border-bottom-color: #e2e4ea;
}
:global(body:not(.dark)) .sidebar-collapse-btn {
color: #9a9caa;
}
:global(body:not(.dark)) .sidebar-collapse-btn:hover {
background: #e8eaf0;
color: #3e4050;
}
:global(body:not(.dark)) .section-toggle:hover {
background: #f0f1f5;
}
:global(body:not(.dark)) .section-label {
color: #6b6e78;
}
:global(body:not(.dark)) .section-count {
color: #b0b2bc;
}
:global(body:not(.dark)) .section-chevron {
color: #9a9caa;
}
:global(body:not(.dark)) .chip:hover {
background: #f0f1f5;
}
:global(body:not(.dark)) .chip-label {
color: #1a1b25;
}
:global(body:not(.dark)) .chip-link {
color: #9a9caa;
}
:global(body:not(.dark)) .chip-link:hover {
color: #1a1b25;
}
:global(body:not(.dark)) .category-label {
color: #9a9caa;
}
:global(body:not(.dark)) .category-count {
color: #b0b2bc;
}
:global(body:not(.dark)) .space-card:hover {
background: #f0f1f5;
}
:global(body:not(.dark)) .space-card-name {
color: #1a1b25;
}
:global(body:not(.dark)) .space-card-likes {
color: #9a9caa;
}
:global(body:not(.dark)) .space-card-link {
color: #9a9caa;
}
:global(body:not(.dark)) .space-card-link:hover {
color: #1a1b25;
}
:global(body:not(.dark)) .space-card-id {
color: #b0b2bc;
}
:global(body:not(.dark)) .space-card-desc {
color: #9a9caa;
}
:global(body:not(.dark)) .space-card-status {
background: #b0b2bc;
}
:global(body:not(.dark)) .trending-status-text {
color: #9a9caa;
}
</style>