mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-05 17:27:42 +02:00
[eric] marketplace: the published .swarm catalog is the store tab, installed through the existing import door
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012G8kyALnPjsA7aJFmMBq3R
This commit is contained in:
co-authored by
Claude Opus 5
parent
f57e9509f8
commit
8afec84986
@@ -0,0 +1,178 @@
|
||||
"""The marketplace catalog: a public Google Sheet read as CSV and normalized into listings.
|
||||
|
||||
The sheet is the publisher's surface, so a package appears by adding a row and needs no OpenSwarm
|
||||
deploy and no credential. Drive share links are rewritten to direct-download form because a share
|
||||
link serves an HTML page, not the bundle.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import urllib.request
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typeguard import typechecked
|
||||
|
||||
# The published catalog. Overridable so a fork or a staging sheet can be pointed at without a build.
|
||||
DEFAULT_SHEET_ID = "1HInP47Vj-daPYKIl02YrAbyKsX1GQ_FSPSm_YF2QIWw"
|
||||
SHEET_ID_ENV = "OPENSWARM_MARKETPLACE_SHEET_ID"
|
||||
|
||||
CACHE_TTL_SECONDS = 600
|
||||
FETCH_TIMEOUT_SECONDS = 15
|
||||
# A sheet is text; anything this large is a wrong URL answering, not a catalog.
|
||||
MAX_SHEET_BYTES = 4 * 1024 * 1024
|
||||
|
||||
P_SHEET_ID_RE = re.compile(r"/spreadsheets/d/([A-Za-z0-9_-]+)")
|
||||
P_DRIVE_ID_RE = re.compile(r"(?:/d/|id=)([A-Za-z0-9_-]{20,})")
|
||||
|
||||
|
||||
class Listing(BaseModel):
|
||||
"""One row of the sheet. Every field is a string because a spreadsheet cell is a string, and a
|
||||
publisher leaving one blank must never break the catalog for everyone else."""
|
||||
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
id: str
|
||||
title: str = ""
|
||||
kind: str = ""
|
||||
version: str = ""
|
||||
author: str = ""
|
||||
description: str = ""
|
||||
tags: str = ""
|
||||
download_url: str = ""
|
||||
icon_url: str = ""
|
||||
video_url: str = ""
|
||||
size: str = ""
|
||||
updated_at: str = ""
|
||||
# A bundle groups already-published listings; this holds their comma-separated ids.
|
||||
bundle_items: str = ""
|
||||
# The publisher's long-form page, converted to our block JSON at publish time.
|
||||
notion_url: str = ""
|
||||
details_json: str = ""
|
||||
|
||||
|
||||
class CatalogResponse(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
source: str
|
||||
count: int
|
||||
listings: List[Listing] = Field(default_factory=list)
|
||||
error: str = ""
|
||||
fetched_at: float = 0.0
|
||||
|
||||
|
||||
KNOWN_FIELDS = tuple(Listing.model_fields.keys())
|
||||
|
||||
p_cache: Optional[CatalogResponse] = None
|
||||
|
||||
|
||||
@typechecked
|
||||
def sheet_id() -> str:
|
||||
"""The configured sheet, accepting either a raw id or a pasted sheet URL."""
|
||||
raw = (os.environ.get(SHEET_ID_ENV) or "").strip() or DEFAULT_SHEET_ID
|
||||
match = P_SHEET_ID_RE.search(raw)
|
||||
return match.group(1) if match else raw
|
||||
|
||||
|
||||
@typechecked
|
||||
def csv_export_url(sid: str) -> str:
|
||||
return f"https://docs.google.com/spreadsheets/d/{sid}/export?format=csv"
|
||||
|
||||
|
||||
@typechecked
|
||||
def normalize_download_url(url: str) -> str:
|
||||
"""A Drive share link serves a viewer page; the installer needs the bytes."""
|
||||
url = (url or "").strip()
|
||||
if not url or "drive.google.com" not in url:
|
||||
return url
|
||||
match = P_DRIVE_ID_RE.search(url)
|
||||
return f"https://drive.google.com/uc?export=download&id={match.group(1)}" if match else url
|
||||
|
||||
|
||||
@typechecked
|
||||
def normalize_video_url(url: str) -> str:
|
||||
"""Drive video links are stored in several hand-pasted shapes; keep one embeddable form."""
|
||||
url = (url or "").strip()
|
||||
if not url or "drive.google.com" not in url or "/preview" in url:
|
||||
return url
|
||||
match = P_DRIVE_ID_RE.search(url)
|
||||
return f"https://drive.google.com/file/d/{match.group(1)}/preview" if match else url
|
||||
|
||||
|
||||
@typechecked
|
||||
def normalize_video_field(raw: str) -> str:
|
||||
"""One cell can carry several demo videos, one URL per line, primary first."""
|
||||
lines = [normalize_video_url(line) for line in re.split(r"[\r\n]+", raw or "") if line.strip()]
|
||||
return "\n".join(line for line in lines if line)
|
||||
|
||||
|
||||
@typechecked
|
||||
def slug_from_title(title: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
|
||||
|
||||
|
||||
@typechecked
|
||||
def parse_csv(text: str) -> List[Listing]:
|
||||
"""Rows the sheet's own header names, loosely matched so a sloppy header still parses. Unknown
|
||||
columns are ignored rather than rejected, so a publisher can add their own without a release."""
|
||||
listings: List[Listing] = []
|
||||
for row in csv.DictReader(io.StringIO(text)):
|
||||
norm: Dict[str, str] = {
|
||||
(key or "").strip().lower().replace(" ", "_"): (value or "").strip()
|
||||
for key, value in row.items()
|
||||
if key
|
||||
}
|
||||
values = {field: norm.get(field, "") for field in KNOWN_FIELDS}
|
||||
if not values["id"] and not values["title"]:
|
||||
continue
|
||||
if not values["id"]:
|
||||
values["id"] = slug_from_title(values["title"])
|
||||
if not values["id"]:
|
||||
continue
|
||||
values["download_url"] = normalize_download_url(values["download_url"])
|
||||
values["video_url"] = normalize_video_field(values["video_url"])
|
||||
listings.append(Listing(**values))
|
||||
return listings
|
||||
|
||||
|
||||
@typechecked
|
||||
def fetch_sheet_csv(url: str) -> str:
|
||||
"""Stdlib only: this runs on end-user machines, and the sheet is public so there is no auth."""
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "OpenSwarm-Marketplace/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=FETCH_TIMEOUT_SECONDS) as response:
|
||||
raw = response.read(MAX_SHEET_BYTES + 1)
|
||||
if len(raw) > MAX_SHEET_BYTES:
|
||||
raise ValueError("catalog response is too large to be a sheet")
|
||||
charset = response.headers.get_content_charset() or "utf-8"
|
||||
return raw.decode(charset, errors="replace")
|
||||
|
||||
|
||||
@typechecked
|
||||
def load_catalog(force: bool = False) -> CatalogResponse:
|
||||
"""The catalog, from cache when fresh. A failed fetch serves the last good copy rather than an
|
||||
empty store, and says so, because a flaky network must not look like an empty marketplace."""
|
||||
global p_cache
|
||||
now = time.time()
|
||||
if not force and p_cache is not None and now - p_cache.fetched_at < CACHE_TTL_SECONDS:
|
||||
return p_cache
|
||||
try:
|
||||
listings = parse_csv(fetch_sheet_csv(csv_export_url(sheet_id())))
|
||||
except Exception as e:
|
||||
if p_cache is not None:
|
||||
return p_cache.model_copy(update={"source": "cache", "error": f"Could not refresh the catalog: {e}"})
|
||||
return CatalogResponse(source="empty", count=0, listings=[], error=f"Could not reach the catalog: {e}", fetched_at=now)
|
||||
p_cache = CatalogResponse(source="sheet", count=len(listings), listings=listings, fetched_at=now)
|
||||
return p_cache
|
||||
|
||||
|
||||
@typechecked
|
||||
def find_listing(listing_id: str) -> Optional[Listing]:
|
||||
"""Resolve an id against OUR fetched catalog; a caller never gets to name a URL to fetch."""
|
||||
wanted = (listing_id or "").strip()
|
||||
for listing in load_catalog().listings:
|
||||
if listing.id == wanted:
|
||||
return listing
|
||||
return None
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Marketplace routes: browse the published catalog, and stage a package for install.
|
||||
|
||||
Install deliberately owns no writing of its own. It downloads the bundle and hands the bytes to the
|
||||
same staging door a dropped .swarm goes through, so the conflict check, the secret review and the
|
||||
"a skill never installs silently" rule are the ones already shipped, not a second copy that can
|
||||
drift away from them.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from backend.apps.marketplace import catalog
|
||||
from backend.apps.marketplace.package_download import (
|
||||
DownloadRefused,
|
||||
download_package,
|
||||
package_filename,
|
||||
)
|
||||
from backend.apps.swarm.models import ImportPreflightResponse
|
||||
from backend.apps.swarm.swarm import stage_bundle_for_import
|
||||
from backend.config.Apps import SubApp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InstallRequest(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
id: str
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def marketplace_lifespan():
|
||||
yield
|
||||
|
||||
|
||||
marketplace = SubApp("marketplace", marketplace_lifespan)
|
||||
|
||||
|
||||
@marketplace.router.get("/listings")
|
||||
async def get_listings(refresh: bool = False) -> catalog.CatalogResponse:
|
||||
"""The published catalog. Never raises: an unreachable sheet answers with the last good copy
|
||||
and an error string, because a browse surface that 500s reads as "the marketplace is gone"."""
|
||||
return await asyncio.to_thread(catalog.load_catalog, refresh)
|
||||
|
||||
|
||||
@marketplace.router.post("/install/preflight")
|
||||
async def install_preflight(body: InstallRequest) -> ImportPreflightResponse:
|
||||
"""Download the listing's bundle and stage it for the ordinary import confirm flow."""
|
||||
listing = await asyncio.to_thread(catalog.find_listing, body.id)
|
||||
if listing is None:
|
||||
raise HTTPException(status_code=404, detail="that package is not in the catalog any more")
|
||||
if not listing.download_url:
|
||||
raise HTTPException(status_code=400, detail="this listing has no package file yet")
|
||||
try:
|
||||
raw = await asyncio.to_thread(download_package, listing.download_url)
|
||||
except DownloadRefused as e:
|
||||
logger.warning("marketplace download refused for %s: %s", listing.id, e)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
return stage_bundle_for_import(raw, package_filename(listing.id, listing.title))
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Fetch a published .swarm, with the host allowlist that keeps this from being an SSRF hole.
|
||||
|
||||
The catalog is a spreadsheet other people can edit, so a row's URL is untrusted input that would
|
||||
otherwise make OUR backend fetch whatever it names, including localhost and cloud metadata. Every
|
||||
hop of the redirect chain is checked, not just the first, because a permitted host may redirect.
|
||||
"""
|
||||
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
ALLOWED_DOWNLOAD_HOSTS = (
|
||||
"drive.google.com",
|
||||
"drive.usercontent.google.com",
|
||||
"docs.google.com",
|
||||
"github.com",
|
||||
"objects.githubusercontent.com",
|
||||
"raw.githubusercontent.com",
|
||||
)
|
||||
|
||||
DOWNLOAD_TIMEOUT_SECONDS = 60
|
||||
MAX_PACKAGE_BYTES = 200 * 1024 * 1024
|
||||
|
||||
|
||||
class DownloadRefused(Exception):
|
||||
"""The URL is not somewhere we are willing to fetch from."""
|
||||
|
||||
|
||||
@typechecked
|
||||
def host_allowed(url: str) -> bool:
|
||||
parsed = urlparse((url or "").strip())
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
return False
|
||||
host = parsed.hostname.lower()
|
||||
return any(host == allowed or host.endswith("." + allowed) for allowed in ALLOWED_DOWNLOAD_HOSTS)
|
||||
|
||||
|
||||
class AllowlistRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Drive answers a download with a redirect, so redirects have to be followed; each new
|
||||
location is re-checked so a permitted host cannot bounce us onto a private address."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
|
||||
if not host_allowed(newurl):
|
||||
raise DownloadRefused(f"refused a redirect to {urlparse(newurl).hostname or newurl}")
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
@typechecked
|
||||
def download_package(url: str) -> bytes:
|
||||
"""The bundle's bytes, or DownloadRefused. Never returns a partial or oversized body."""
|
||||
if not host_allowed(url):
|
||||
raise DownloadRefused("this package is not hosted somewhere OpenSwarm will download from")
|
||||
opener = urllib.request.build_opener(AllowlistRedirectHandler())
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "OpenSwarm-Marketplace/1.0"})
|
||||
try:
|
||||
with opener.open(request, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response:
|
||||
raw = response.read(MAX_PACKAGE_BYTES + 1)
|
||||
except urllib.error.HTTPError as e:
|
||||
raise DownloadRefused(f"the download returned {e.code}")
|
||||
except DownloadRefused:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise DownloadRefused(f"the download failed: {e}")
|
||||
if len(raw) > MAX_PACKAGE_BYTES:
|
||||
raise DownloadRefused("the package is too large")
|
||||
if not raw:
|
||||
raise DownloadRefused("the download was empty")
|
||||
return raw
|
||||
|
||||
|
||||
@typechecked
|
||||
def package_filename(listing_id: str, title: str) -> str:
|
||||
base = (listing_id or title or "package").strip() or "package"
|
||||
return f"{base}.swarm"
|
||||
@@ -82,13 +82,14 @@ async def export_bundle(body: ExportRequest) -> Response:
|
||||
)
|
||||
|
||||
|
||||
@swarm.router.post("/import/preflight")
|
||||
async def import_preflight(file: UploadFile = File(...)) -> ImportPreflightResponse:
|
||||
raw = await file.read()
|
||||
def stage_bundle_for_import(raw: bytes, filename: str) -> ImportPreflightResponse:
|
||||
"""Stage bytes for import and hand back the review. Every door that installs a bundle comes
|
||||
through here (a dropped file, a marketplace package), so the conflict/secret review and the
|
||||
one staging store can never diverge per door."""
|
||||
if len(raw) > MAX_TOTAL_BYTES:
|
||||
raise HTTPException(status_code=400, detail="file is too large")
|
||||
try:
|
||||
sandbox, manifest, warnings = closure.stage_upload(raw, file.filename or "")
|
||||
sandbox, manifest, warnings = closure.stage_upload(raw, filename)
|
||||
conflicts = closure.detect_conflicts(sandbox, manifest)
|
||||
review = closure.review_bundle(sandbox, manifest)
|
||||
except BundleError as e:
|
||||
@@ -105,6 +106,12 @@ async def import_preflight(file: UploadFile = File(...)) -> ImportPreflightRespo
|
||||
)
|
||||
|
||||
|
||||
@swarm.router.post("/import/preflight")
|
||||
async def import_preflight(file: UploadFile = File(...)) -> ImportPreflightResponse:
|
||||
raw = await file.read()
|
||||
return stage_bundle_for_import(raw, file.filename or "")
|
||||
|
||||
|
||||
@swarm.router.post("/import/commit")
|
||||
async def import_commit(body: ImportCommitRequest) -> ImportCommitResponse:
|
||||
entry = P_STAGING.get(body.staging_token)
|
||||
|
||||
+2
-1
@@ -43,6 +43,7 @@ from backend.apps.outputs.outputs import outputs
|
||||
from backend.apps.outputs.versions_routes import output_versions
|
||||
from backend.apps.dashboards.dashboards import dashboards
|
||||
from backend.apps.swarm.swarm import swarm
|
||||
from backend.apps.marketplace.marketplace import marketplace
|
||||
from backend.apps.service.service import service
|
||||
from backend.apps.subscription.router import subscription
|
||||
from backend.apps.auth.router import auth
|
||||
@@ -60,7 +61,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
import json
|
||||
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, service, subscription, auth, web, onboarding, voice, memory, help_app, anthropic_proxy, workflows, cloud_workflows, openai_passthrough, apps_sdk])
|
||||
main_app = MainApp([health, agents, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, output_versions, dashboards, swarm, marketplace, service, subscription, auth, web, onboarding, voice, memory, help_app, anthropic_proxy, workflows, cloud_workflows, openai_passthrough, apps_sdk])
|
||||
app = main_app.app
|
||||
|
||||
# Generate per-install auth token BEFORE we bind the HTTP port. By the time any request lands, the token file exists. See backend/auth.py.
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""The marketplace catalog and its one install door.
|
||||
|
||||
Two things here are load-bearing and are asserted rather than assumed: the catalog is a spreadsheet
|
||||
other people can edit, so a row's download URL is untrusted input, and install must go through the
|
||||
SAME staging door a dropped .swarm uses, or the secret review and the never-install-a-skill-silently
|
||||
rule would exist in one door and not the other.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.apps.marketplace import catalog, marketplace
|
||||
from backend.apps.marketplace.package_download import (
|
||||
DownloadRefused,
|
||||
download_package,
|
||||
host_allowed,
|
||||
)
|
||||
|
||||
SHEET = (
|
||||
"id,title,kind,version,author,description,tags,icon_url,download_url,size,updated_at,video_url,bundle_items\n"
|
||||
"hello,Hello World,skill,1.0.0,Test,Says hello,demo,,https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUv/view,12 KB,2026-09-01,,\n"
|
||||
",Derived Title,app,,Someone,No id column,,,https://example.com/x.swarm,,,,\n"
|
||||
",,,,,,,,,,,,\n"
|
||||
"pack,Starter Pack,bundle,,Team,Two things,,,,,2026-09-02,,hello, hello ,\n"
|
||||
)
|
||||
|
||||
|
||||
def p_listing(rows, listing_id):
|
||||
return next(row for row in rows if row.id == listing_id)
|
||||
|
||||
|
||||
def test_a_drive_share_link_becomes_something_an_installer_can_fetch():
|
||||
rows = catalog.parse_csv(SHEET)
|
||||
assert p_listing(rows, "hello").download_url == (
|
||||
"https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUv"
|
||||
)
|
||||
|
||||
|
||||
def test_a_row_without_an_id_gets_one_from_its_title_and_a_blank_row_is_dropped():
|
||||
rows = catalog.parse_csv(SHEET)
|
||||
assert [row.id for row in rows] == ["hello", "derived-title", "pack"]
|
||||
|
||||
|
||||
def test_an_unknown_column_is_ignored_rather_than_rejected():
|
||||
rows = catalog.parse_csv("id,title,kind,publisher_notes\nx,X,skill,anything at all\n")
|
||||
assert rows[0].id == "x" and rows[0].kind == "skill"
|
||||
|
||||
|
||||
def test_several_demo_videos_in_one_cell_each_normalize_and_keep_their_order():
|
||||
raw = "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUv/view\nhttps://youtu.be/abcdefghijk"
|
||||
rows = catalog.parse_csv(f"id,title,video_url\nv,V,\"{raw}\"\n")
|
||||
assert rows[0].video_url.split("\n") == [
|
||||
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUv/preview",
|
||||
"https://youtu.be/abcdefghijk",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url,allowed",
|
||||
[
|
||||
("https://drive.google.com/uc?export=download&id=x", True),
|
||||
("https://drive.usercontent.google.com/download?id=x", True),
|
||||
("https://raw.githubusercontent.com/o/r/main/a.swarm", True),
|
||||
# The sheet is editable by other people, so these are the shapes that must never resolve.
|
||||
("http://drive.google.com/uc?id=x", False),
|
||||
("https://drive.google.com.evil.example/a.swarm", False),
|
||||
("https://127.0.0.1/a.swarm", False),
|
||||
("https://169.254.169.254/latest/meta-data", False),
|
||||
("file:///etc/passwd", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_only_hosts_we_publish_from_are_fetchable(url, allowed):
|
||||
assert host_allowed(url) is allowed
|
||||
|
||||
|
||||
def test_a_refused_host_never_reaches_the_network():
|
||||
with pytest.raises(DownloadRefused):
|
||||
download_package("https://evil.example/package.swarm")
|
||||
|
||||
|
||||
def test_a_redirect_off_the_allowlist_is_refused_mid_chain():
|
||||
from backend.apps.marketplace.package_download import AllowlistRedirectHandler
|
||||
|
||||
handler = AllowlistRedirectHandler()
|
||||
with pytest.raises(DownloadRefused):
|
||||
handler.redirect_request(None, None, 302, "Found", {}, "https://127.0.0.1/pkg.swarm")
|
||||
|
||||
|
||||
def test_an_unreachable_sheet_serves_the_last_good_catalog_instead_of_an_empty_store(monkeypatch):
|
||||
monkeypatch.setattr(catalog, "fetch_sheet_csv", lambda url: SHEET)
|
||||
good = catalog.load_catalog(force=True)
|
||||
assert good.source == "sheet" and good.count == 3
|
||||
|
||||
def p_down(url):
|
||||
raise OSError("no network")
|
||||
|
||||
monkeypatch.setattr(catalog, "fetch_sheet_csv", p_down)
|
||||
stale = catalog.load_catalog(force=True)
|
||||
assert stale.source == "cache", "a flaky network must not look like an empty marketplace"
|
||||
assert [row.id for row in stale.listings] == [row.id for row in good.listings]
|
||||
assert "Could not refresh" in stale.error
|
||||
|
||||
|
||||
def test_with_no_cache_at_all_an_unreachable_sheet_is_honest_rather_than_silent(monkeypatch):
|
||||
monkeypatch.setattr(catalog, "p_cache", None)
|
||||
|
||||
def p_down(url):
|
||||
raise OSError("no network")
|
||||
|
||||
monkeypatch.setattr(catalog, "fetch_sheet_csv", p_down)
|
||||
empty = catalog.load_catalog(force=True)
|
||||
assert empty.source == "empty" and empty.count == 0 and empty.error
|
||||
|
||||
|
||||
def test_a_caller_names_a_listing_id_never_a_url():
|
||||
"""The install request model carries only an id, so no caller can make the backend fetch a URL
|
||||
of their choosing; the URL is resolved from the catalog WE fetched."""
|
||||
assert set(marketplace.InstallRequest.model_fields) == {"id"}
|
||||
|
||||
|
||||
def test_install_stages_through_the_same_door_as_a_dropped_file():
|
||||
from backend.apps.swarm import swarm as swarm_routes
|
||||
|
||||
assert marketplace.stage_bundle_for_import is swarm_routes.stage_bundle_for_import
|
||||
body = inspect.getsource(marketplace.install_preflight)
|
||||
assert "stage_bundle_for_import" in body
|
||||
for forbidden in ("write_folder_skill", "closure.commit", "import_commit"):
|
||||
assert forbidden not in body, "install must never write; it stages and lets the user confirm"
|
||||
@@ -0,0 +1,233 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { fetchMarketplaceListings } from '@/shared/state/marketplaceCatalogSlice';
|
||||
import { fetchSkills } from '@/shared/state/skillsSlice';
|
||||
import { fetchOutputs } from '@/shared/state/outputsSlice';
|
||||
import { fetchWorkflows } from '@/shared/state/workflowsSlice';
|
||||
import ImportModal from '@/app/components/share/ImportModal';
|
||||
import { importNeedsConfirm } from '@/app/components/share/importNeedsConfirm';
|
||||
import { importCommit } from '@/app/components/share/shareApi';
|
||||
import type { ImportPreflight } from '@/app/components/share/shareTypes';
|
||||
import DirectoryFilterBar from './DirectoryFilterBar';
|
||||
import PackageCard from './packages/PackageCard';
|
||||
import PackageDialog from './packages/detail/PackageDialog';
|
||||
import PackageBundleCard from './packages/PackageBundleCard';
|
||||
import PackageBundleDialog from './packages/detail/PackageBundleDialog';
|
||||
import { stagePackageInstall } from './packages/installPackage';
|
||||
import { KIND_LABELS, isBundle, resolveBundleMembers, type Listing } from './packages/catalog';
|
||||
|
||||
type Toast = { message: string; severity: 'success' | 'error' } | null;
|
||||
|
||||
// The store tab: packages published to the marketplace sheet. Install downloads the .swarm and hands
|
||||
// it to the ordinary bundle import, so what a package can do to this machine is reviewed the same way
|
||||
// a dropped file is.
|
||||
const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void }> = ({ onInstalled }) => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const { listings, loading, loaded, source, error } = useAppSelector((s) => s.marketplaceCatalog);
|
||||
const [query, setQuery] = useState('');
|
||||
const [kinds, setKinds] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState('newest');
|
||||
const [openListing, setOpenListing] = useState<Listing | null>(null);
|
||||
const [openBundle, setOpenBundle] = useState<Listing | null>(null);
|
||||
const [installingId, setInstallingId] = useState<string | null>(null);
|
||||
const [installedIds, setInstalledIds] = useState<string[]>([]);
|
||||
const [confirm, setConfirm] = useState<{ preflight: ImportPreflight; listingId: string } | null>(null);
|
||||
const [committing, setCommitting] = useState(false);
|
||||
const [toast, setToast] = useState<Toast>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchMarketplaceListings(false));
|
||||
}, [dispatch]);
|
||||
|
||||
const packages = useMemo(() => listings.filter((l) => !isBundle(l)), [listings]);
|
||||
const bundles = useMemo(() => listings.filter(isBundle), [listings]);
|
||||
const kindOptions = useMemo(
|
||||
() => Array.from(new Set(packages.map((l) => l.kind).filter(Boolean))).sort()
|
||||
.map((k) => ({ value: k, label: KIND_LABELS[k] || k })),
|
||||
[packages],
|
||||
);
|
||||
|
||||
const matchesQuery = (l: Listing, q: string): boolean =>
|
||||
!q || [l.title, l.description, l.tags, l.author].some((field) => field.toLowerCase().includes(q));
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const rows = packages.filter((l) => (kinds.length === 0 || kinds.includes(l.kind)) && matchesQuery(l, q));
|
||||
return [...rows].sort((a, b) => {
|
||||
if (sort === 'name') return a.title.localeCompare(b.title);
|
||||
if (sort === 'kind') return a.kind.localeCompare(b.kind);
|
||||
return (b.updated_at || '').localeCompare(a.updated_at || '');
|
||||
});
|
||||
}, [packages, query, kinds, sort]);
|
||||
|
||||
// A bundle has no single kind, so it leaves the view entirely once the user narrows to one.
|
||||
const visibleBundles = useMemo(() => {
|
||||
if (kinds.length > 0) return [];
|
||||
const q = query.trim().toLowerCase();
|
||||
return bundles.filter((b) => matchesQuery(b, q));
|
||||
}, [bundles, query, kinds]);
|
||||
|
||||
const finish = (preflight: ImportPreflight, listingId: string, rootType: string) => {
|
||||
setInstalledIds((prev) => (prev.includes(listingId) ? prev : [...prev, listingId]));
|
||||
setToast({ message: `Added ${preflight.summary.root.name}`, severity: 'success' });
|
||||
// Nothing else refetches these on import, so an installed package would otherwise stay invisible.
|
||||
if (rootType === 'skill') dispatch(fetchSkills());
|
||||
if (rootType === 'app') dispatch(fetchOutputs());
|
||||
if (rootType === 'workflow') dispatch(fetchWorkflows(undefined));
|
||||
onInstalled?.(rootType);
|
||||
};
|
||||
|
||||
const commit = async (preflight: ImportPreflight, listingId: string) => {
|
||||
setCommitting(true);
|
||||
try {
|
||||
const res = await importCommit(preflight.staging_token);
|
||||
finish(preflight, listingId, res.root_type);
|
||||
setConfirm(null);
|
||||
} catch (e: unknown) {
|
||||
setToast({ message: e instanceof Error ? e.message : "We couldn't finish the install.", severity: 'error' });
|
||||
} finally {
|
||||
setCommitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const install = async (listingId: string) => {
|
||||
setInstallingId(listingId);
|
||||
try {
|
||||
const preflight = await stagePackageInstall(listingId);
|
||||
if (importNeedsConfirm(preflight)) setConfirm({ preflight, listingId });
|
||||
else await commit(preflight, listingId);
|
||||
} catch (e: unknown) {
|
||||
setToast({ message: e instanceof Error ? e.message : "We couldn't download this package.", severity: 'error' });
|
||||
} finally {
|
||||
setInstallingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const installBundle = async (bundle: Listing) => {
|
||||
const members = resolveBundleMembers(bundle, listings).filter((m) => m.download_url);
|
||||
if (members.length === 0) {
|
||||
setToast({ message: 'This bundle has no installable packages yet.', severity: 'error' });
|
||||
return;
|
||||
}
|
||||
// One at a time: each member gets its own review, and a bundle must not be a way to skip one.
|
||||
for (const member of members) {
|
||||
await install(member.id);
|
||||
}
|
||||
};
|
||||
|
||||
const body = (): React.ReactElement => {
|
||||
if (loading && !loaded) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 8 }}>
|
||||
<CircularProgress size={24} sx={{ color: c.accent.primary }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (visible.length === 0 && visibleBundles.length === 0) {
|
||||
return (
|
||||
<Typography sx={{ fontSize: '0.9375rem', color: c.text.tertiary, pt: 6, textAlign: 'center' }}>
|
||||
{error ? error : 'No packages match that search yet.'}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{visibleBundles.length > 0 && (
|
||||
<Box sx={{ mb: 2.5 }}>
|
||||
<Typography sx={{ fontSize: '0.8125rem', fontWeight: 600, color: c.text.tertiary, mb: 1 }}>
|
||||
Collections
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.75 }}>
|
||||
{visibleBundles.map((bundle) => (
|
||||
<PackageBundleCard
|
||||
key={bundle.id}
|
||||
bundle={bundle}
|
||||
members={resolveBundleMembers(bundle, listings)}
|
||||
onOpen={() => setOpenBundle(bundle)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.75, alignContent: 'start' }}>
|
||||
{visible.map((listing) => (
|
||||
<PackageCard
|
||||
key={listing.id}
|
||||
listing={listing}
|
||||
onOpen={() => setOpenListing(listing)}
|
||||
onTag={(tag) => setQuery(tag)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, gap: 1.75 }}>
|
||||
<DirectoryFilterBar
|
||||
searchPlaceholder="Search packages, tags, authors"
|
||||
query={query}
|
||||
onQuery={setQuery}
|
||||
filterSections={[{ label: 'Kind', options: kindOptions }]}
|
||||
filterSelected={kinds}
|
||||
onToggleFilter={(value) => setKinds((prev) => (prev.includes(value) ? prev.filter((k) => k !== value) : [...prev, value]))}
|
||||
sortOptions={[
|
||||
{ value: 'newest', label: 'Newest' },
|
||||
{ value: 'name', label: 'Name' },
|
||||
{ value: 'kind', label: 'Kind' },
|
||||
]}
|
||||
sortValue={sort}
|
||||
onSort={setSort}
|
||||
/>
|
||||
{source === 'cache' && (
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.muted }}>
|
||||
Showing the last catalog we loaded; the marketplace could not be reached just now.
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', pr: 0.5 }}>{body()}</Box>
|
||||
<PackageDialog
|
||||
listing={openListing}
|
||||
installing={!!openListing && installingId === openListing.id}
|
||||
installed={!!openListing && installedIds.includes(openListing.id)}
|
||||
onInstall={() => { if (openListing) void install(openListing.id); }}
|
||||
onClose={() => setOpenListing(null)}
|
||||
/>
|
||||
<PackageBundleDialog
|
||||
bundle={openBundle}
|
||||
members={openBundle ? resolveBundleMembers(openBundle, listings) : []}
|
||||
installing={installingId !== null}
|
||||
onInstallAll={() => { if (openBundle) void installBundle(openBundle); }}
|
||||
onInstallMember={(id) => { void install(id); }}
|
||||
onOpenMember={(member) => { setOpenBundle(null); setOpenListing(member); }}
|
||||
onClose={() => setOpenBundle(null)}
|
||||
/>
|
||||
<ImportModal
|
||||
preflight={confirm?.preflight ?? null}
|
||||
open={!!confirm}
|
||||
committing={committing}
|
||||
onConfirm={() => confirm && commit(confirm.preflight, confirm.listingId)}
|
||||
onClose={() => setConfirm(null)}
|
||||
/>
|
||||
<Snackbar
|
||||
open={!!toast}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setToast(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity={toast?.severity || 'success'} variant="filled" onClose={() => setToast(null)}>
|
||||
{toast?.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DirectoryPackagesTab;
|
||||
@@ -2,31 +2,31 @@ import React, { useState, useEffect } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import GridViewOutlinedIcon from '@mui/icons-material/GridViewOutlined';
|
||||
import FolderSpecialOutlinedIcon from '@mui/icons-material/FolderSpecialOutlined';
|
||||
import PowerOutlinedIcon from '@mui/icons-material/PowerOutlined';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { clearMarketplaceRequestedTab } from '@/shared/state/dashboardLayoutSlice';
|
||||
import DirectorySkillsTab from './DirectorySkillsTab';
|
||||
import DirectoryPackagesTab from './DirectoryPackagesTab';
|
||||
import DirectoryConnectorsTab from './DirectoryConnectorsTab';
|
||||
|
||||
// Lazy: the manage views pull the full Skills/Tools pages (markdown, MCP cards) and the marketplace opens from the dock.
|
||||
const SkillsManage = React.lazy(() => import('../Skills/Skills'));
|
||||
const ToolsManage = React.lazy(() => import('../Tools/Tools'));
|
||||
|
||||
export type DirectoryTab = 'skills' | 'connectors' | 'my-skills' | 'my-connectors';
|
||||
export type DirectoryTab = 'packages' | 'connectors' | 'my-skills' | 'my-connectors';
|
||||
|
||||
const VALID_TABS: DirectoryTab[] = ['skills', 'connectors', 'my-skills', 'my-connectors'];
|
||||
const VALID_TABS: DirectoryTab[] = ['packages', 'connectors', 'my-skills', 'my-connectors'];
|
||||
|
||||
// The Marketplace window body: claude.ai's Directory grids land first (the store), with the
|
||||
// The Marketplace window body: the published package catalog lands first (the store), with the
|
||||
// installed-item manage pages as their own rail rows below the divider.
|
||||
const MarketplaceBody: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const requestedTab = useAppSelector((s) => s.dashboardLayout.marketplaceRequestedTab);
|
||||
const [view, setView] = useState<DirectoryTab>('skills');
|
||||
const [view, setView] = useState<DirectoryTab>('packages');
|
||||
const [focusSkillId, setFocusSkillId] = useState<string | null>(null);
|
||||
const [focusToolId, setFocusToolId] = useState<string | null>(null);
|
||||
|
||||
@@ -65,12 +65,12 @@ const MarketplaceBody: React.FC = () => {
|
||||
|
||||
const content = (): React.ReactElement => {
|
||||
switch (view) {
|
||||
case 'skills':
|
||||
return <DirectorySkillsTab onOpenInstalled={(id) => { setFocusSkillId(id); setView('my-skills'); }} />;
|
||||
case 'packages':
|
||||
return <DirectoryPackagesTab onInstalled={(rootType) => { if (rootType === 'skill') { setFocusSkillId(null); setView('my-skills'); } }} />;
|
||||
case 'connectors':
|
||||
return <DirectoryConnectorsTab onOpenInstalled={(id) => { setFocusToolId(id); setView('my-connectors'); }} />;
|
||||
case 'my-skills':
|
||||
return <SkillsManage onBrowseDirectory={() => setView('skills')} focusSkillId={focusSkillId} />;
|
||||
return <SkillsManage onBrowseDirectory={() => setView('packages')} focusSkillId={focusSkillId} />;
|
||||
case 'my-connectors':
|
||||
return <ToolsManage onBrowseConnectors={() => setView('connectors')} expandToolId={focusToolId} />;
|
||||
}
|
||||
@@ -80,7 +80,7 @@ const MarketplaceBody: React.FC = () => {
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0, bgcolor: c.bg.surface }}>
|
||||
<Box sx={{ display: 'flex', flex: 1, minHeight: 0, pt: 1.5 }}>
|
||||
<Box sx={{ width: 210, minWidth: 210, px: 2, pt: 0.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{railRow('skills', 'Skills', <DescriptionOutlinedIcon sx={{ fontSize: 19, color: c.text.secondary }} />)}
|
||||
{railRow('packages', 'Packages', <Inventory2OutlinedIcon sx={{ fontSize: 19, color: c.text.secondary }} />)}
|
||||
{railRow('connectors', 'Connectors', <GridViewOutlinedIcon sx={{ fontSize: 19, color: c.text.secondary }} />)}
|
||||
<Box sx={{ height: '1px', bgcolor: c.border.subtle, mx: 1.5, my: 1 }} />
|
||||
{railRow('my-skills', 'My skills', <FolderSpecialOutlinedIcon sx={{ fontSize: 19, color: c.text.secondary }} />)}
|
||||
|
||||
@@ -2,6 +2,6 @@ import { store } from '@/shared/state/store';
|
||||
import { openMarketplaceCard } from '@/shared/state/dashboardLayoutSlice';
|
||||
import type { DirectoryTab } from './MarketplaceBody';
|
||||
|
||||
export function openMarketplace(tab: DirectoryTab = 'skills'): void {
|
||||
export function openMarketplace(tab: DirectoryTab = 'packages'): void {
|
||||
store.dispatch(openMarketplaceCard({ tab }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Inventory2Icon from '@mui/icons-material/Inventory2';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { KIND_LABELS, type Listing } from './catalog';
|
||||
|
||||
interface Props {
|
||||
bundle: Listing;
|
||||
members: Listing[];
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
// Leads with a stacked-icon motif of its first few members and a count, so a bundle reads as a collection at a glance rather than as another single package.
|
||||
export default function PackageBundleCard({ bundle, members, onOpen }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const preview = members.slice(0, 4);
|
||||
const kinds = Array.from(new Set(members.map((m) => KIND_LABELS[m.kind] || m.kind).filter(Boolean)));
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={onOpen}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cursor: 'pointer',
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 3,
|
||||
p: 2.5,
|
||||
transition: c.transition,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&:hover': {
|
||||
borderColor: c.accent.primary,
|
||||
boxShadow: c.shadow.md,
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 2.5,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: `${c.accent.primary}1A`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{bundle.icon_url ? (
|
||||
<Box component="img" src={bundle.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<Inventory2Icon sx={{ fontSize: 24, color: c.accent.primary }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '1.05rem',
|
||||
fontWeight: 660,
|
||||
color: c.text.primary,
|
||||
letterSpacing: '-0.01em',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{bundle.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.accent.primary, fontWeight: 600 }}>
|
||||
Bundle · {members.length} {members.length === 1 ? 'package' : 'packages'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
color: c.text.secondary,
|
||||
lineHeight: 1.5,
|
||||
mb: 1.75,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
minHeight: '2.6em',
|
||||
}}
|
||||
>
|
||||
{bundle.description || 'A curated collection of packages.'}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={-0.75} sx={{ mt: 'auto', alignItems: 'center' }}>
|
||||
{preview.map((m, i) => (
|
||||
<Box
|
||||
key={m.id}
|
||||
sx={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 1.5,
|
||||
flexShrink: 0,
|
||||
ml: i === 0 ? 0 : '-8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: c.bg.secondary,
|
||||
border: `2px solid ${c.bg.surface}`,
|
||||
overflow: 'hidden',
|
||||
zIndex: preview.length - i,
|
||||
}}
|
||||
>
|
||||
{m.icon_url ? (
|
||||
<Box component="img" src={m.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<ExtensionIcon sx={{ fontSize: 15, color: c.text.muted }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{members.length > preview.length && (
|
||||
<Typography sx={{ ml: 1, fontSize: '0.78rem', color: c.text.muted }}>
|
||||
+{members.length - preview.length} more
|
||||
</Typography>
|
||||
)}
|
||||
{kinds.length > 0 && (
|
||||
<Chip
|
||||
label={kinds.slice(0, 2).join(' · ')}
|
||||
size="small"
|
||||
sx={{
|
||||
ml: 'auto',
|
||||
height: 22,
|
||||
fontSize: '0.72rem',
|
||||
bgcolor: c.bg.secondary,
|
||||
color: c.text.tertiary,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { parseTags, KIND_LABELS, type Listing } from './catalog';
|
||||
|
||||
interface Props {
|
||||
listing: Listing;
|
||||
onOpen: () => void;
|
||||
onTag: (tag: string) => void;
|
||||
}
|
||||
|
||||
export default function PackageCard({ listing, onOpen, onTag }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
const tags = parseTags(listing.tags).slice(0, 3);
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={onOpen}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cursor: 'pointer',
|
||||
bgcolor: c.bg.surface,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 3,
|
||||
p: 2.5,
|
||||
transition: c.transition,
|
||||
'&:hover': {
|
||||
borderColor: c.border.medium,
|
||||
boxShadow: c.shadow.md,
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 2,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: `${c.accent.primary}14`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{listing.icon_url ? (
|
||||
<Box component="img" src={listing.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<ExtensionIcon sx={{ fontSize: 22, color: c.accent.primary }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '1rem',
|
||||
fontWeight: 620,
|
||||
color: c.text.primary,
|
||||
letterSpacing: '-0.01em',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{listing.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>
|
||||
{KIND_LABELS[listing.kind] || listing.kind || 'Package'}
|
||||
{listing.version ? ` · v${listing.version}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
color: c.text.secondary,
|
||||
lineHeight: 1.5,
|
||||
mb: 1.75,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
minHeight: '2.6em',
|
||||
}}
|
||||
>
|
||||
{listing.description || 'No description provided.'}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" spacing={0.75} sx={{ mt: 'auto', flexWrap: 'wrap', gap: 0.75 }}>
|
||||
{tags.map((t) => (
|
||||
<Chip
|
||||
key={t}
|
||||
label={`#${t}`}
|
||||
size="small"
|
||||
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
onTag(t);
|
||||
}}
|
||||
sx={{
|
||||
height: 22,
|
||||
fontSize: '0.72rem',
|
||||
bgcolor: c.bg.secondary,
|
||||
color: c.text.tertiary,
|
||||
'&:hover': { color: c.text.primary },
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { driveStreamUrl, isBundle, parseBundleItems, parseTags, parseVideoUrls, resolveBundleMembers, videoEmbed, type Listing } from './catalog';
|
||||
|
||||
function listing(over: Partial<Listing>): Listing {
|
||||
return {
|
||||
id: 'x', title: 'X', kind: 'skill', version: '', author: '', description: '', tags: '',
|
||||
download_url: '', icon_url: '', video_url: '', size: '', updated_at: '',
|
||||
bundle_items: '', notion_url: '', details_json: '', ...over,
|
||||
};
|
||||
}
|
||||
|
||||
test('a bundle resolves its members in its own order and drops ids that no longer exist', () => {
|
||||
const bundle = listing({ id: 'pack', kind: 'bundle', bundle_items: 'b, a, gone, a' });
|
||||
const all = [bundle, listing({ id: 'a' }), listing({ id: 'b' })];
|
||||
assert.deepEqual(resolveBundleMembers(bundle, all).map((m) => m.id), ['b', 'a']);
|
||||
assert.equal(isBundle(bundle), true);
|
||||
assert.deepEqual(parseBundleItems('b, a, , a'), ['b', 'a'], 'a repeated id is listed once');
|
||||
});
|
||||
|
||||
test('a bundle never lists itself, which would install it forever', () => {
|
||||
const bundle = listing({ id: 'pack', kind: 'bundle', bundle_items: 'pack, a' });
|
||||
assert.deepEqual(resolveBundleMembers(bundle, [bundle, listing({ id: 'a' })]).map((m) => m.id), ['a']);
|
||||
});
|
||||
|
||||
test('tags survive sloppy spacing and empty cells', () => {
|
||||
assert.deepEqual(parseTags(' notion , productivity ,,'), ['notion', 'productivity']);
|
||||
assert.deepEqual(parseTags(''), []);
|
||||
});
|
||||
|
||||
test('a Drive video link becomes a byte stream a video tag can actually play', () => {
|
||||
assert.equal(
|
||||
driveStreamUrl('https://drive.google.com/file/d/1AbCdEf/preview'),
|
||||
'https://drive.usercontent.google.com/download?id=1AbCdEf&export=download',
|
||||
);
|
||||
});
|
||||
|
||||
test('a YouTube listing embeds the player, a Drive listing streams the file', () => {
|
||||
assert.equal(videoEmbed('https://youtu.be/abcdefghijk')?.kind, 'youtube');
|
||||
assert.equal(videoEmbed('https://drive.google.com/file/d/1AbCdEf/preview')?.kind, 'file');
|
||||
assert.equal(videoEmbed(''), null);
|
||||
});
|
||||
|
||||
test('several demo videos in one cell keep their order, primary first', () => {
|
||||
assert.deepEqual(parseVideoUrls('https://a\n\nhttps://b\n'), ['https://a', 'https://b']);
|
||||
});
|
||||
|
||||
// The store tab is the marketplace's front door, so it must be the row that opens by default.
|
||||
test('Packages is the default marketplace tab and the old skills store is gone', () => {
|
||||
const dir = path.join(process.cwd(), 'src/app/pages/Directory');
|
||||
const body = fs.readFileSync(path.join(dir, 'MarketplaceBody.tsx'), 'utf8');
|
||||
assert.match(body, /useState<DirectoryTab>\('packages'\)/);
|
||||
assert.match(body, /railRow\('packages', 'Packages'/);
|
||||
assert.doesNotMatch(body, /DirectorySkillsTab/);
|
||||
assert.equal(fs.existsSync(path.join(dir, 'DirectorySkillsTab.tsx')), false);
|
||||
const open = fs.readFileSync(path.join(dir, 'openMarketplace.ts'), 'utf8');
|
||||
assert.match(open, /DirectoryTab = 'packages'/);
|
||||
});
|
||||
|
||||
// Install must not grow a second write path; it stages and lets the shared confirm surface decide.
|
||||
test('the packages tab installs through the shared bundle import, not its own writer', () => {
|
||||
const tab = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/Directory/DirectoryPackagesTab.tsx'), 'utf8');
|
||||
assert.match(tab, /importNeedsConfirm/);
|
||||
assert.match(tab, /importCommit/);
|
||||
assert.match(tab, /<ImportModal/);
|
||||
const gate = tab.indexOf('importNeedsConfirm(preflight)');
|
||||
const commit = tab.indexOf('else await commit(preflight');
|
||||
assert.ok(gate > 0 && commit > gate, 'the confirm gate is asked BEFORE anything is committed');
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const CATALOG_LISTINGS_URL = `${API_BASE}/catalog/listings`;
|
||||
|
||||
export interface Listing {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: string;
|
||||
version: string;
|
||||
author: string;
|
||||
description: string;
|
||||
tags: string;
|
||||
download_url: string;
|
||||
icon_url: string;
|
||||
video_url: string;
|
||||
size: string;
|
||||
updated_at: string;
|
||||
// Bundles (kind === 'bundle') list their member ids here, comma separated; empty on ordinary packages.
|
||||
bundle_items: string;
|
||||
notion_url: string;
|
||||
// The linked Notion page converted to DetailDoc JSON at upload time; empty when there is no linked page.
|
||||
details_json: string;
|
||||
}
|
||||
|
||||
export interface ListingsResponse {
|
||||
source: 'sheet' | 'sample';
|
||||
count: number;
|
||||
listings: Listing[];
|
||||
error: string;
|
||||
}
|
||||
|
||||
export async function fetchListings(): Promise<ListingsResponse> {
|
||||
const res = await fetch(CATALOG_LISTINGS_URL);
|
||||
if (!res.ok) throw new Error(`Listings request failed (${res.status})`);
|
||||
return (await res.json()) as ListingsResponse;
|
||||
}
|
||||
|
||||
export function parseTags(tags: string): string[] {
|
||||
return (tags || '')
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Drive's /file/d/{id}/preview URL is a player page an HTML5 <video> cannot play (and its iframe embed fails on clips Drive never transcoded), so pull the file id back out and rebuild the Range-enabled byte-stream URL.
|
||||
export function driveStreamUrl(url: string): string {
|
||||
if (!url) return '';
|
||||
if (url.includes('drive.usercontent.google.com/download')) return url;
|
||||
const m = url.match(/\/file\/d\/([^/]+)/) || url.match(/[?&]id=([^&]+)/);
|
||||
const fileId = m?.[1];
|
||||
if (!fileId) return url;
|
||||
return `https://drive.usercontent.google.com/download?id=${fileId}&export=download`;
|
||||
}
|
||||
|
||||
// YouTube hands out the same 11-char id through four URL shapes, and listings carry whichever one the author copied.
|
||||
export function youtubeId(url: string): string | null {
|
||||
if (!url) return null;
|
||||
const m =
|
||||
url.match(/[?&]v=([A-Za-z0-9_-]{11})/) ||
|
||||
url.match(/youtu\.be\/([A-Za-z0-9_-]{11})/) ||
|
||||
url.match(/\/embed\/([A-Za-z0-9_-]{11})/) ||
|
||||
url.match(/\/shorts\/([A-Za-z0-9_-]{11})/);
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
|
||||
export interface VideoEmbed {
|
||||
kind: 'youtube' | 'file';
|
||||
src: string;
|
||||
poster?: string;
|
||||
videoId?: string;
|
||||
}
|
||||
|
||||
// Newer listings host the demo on YouTube (unlisted) and legacy ones on Drive, so the player has to be picked per URL.
|
||||
export function videoEmbed(url: string): VideoEmbed | null {
|
||||
if (!url) return null;
|
||||
const yt = youtubeId(url);
|
||||
if (yt) {
|
||||
return {
|
||||
kind: 'youtube',
|
||||
src: `https://www.youtube.com/embed/${yt}?rel=0&playsinline=1`,
|
||||
poster: `https://i.ytimg.com/vi/${yt}/maxresdefault.jpg`,
|
||||
videoId: yt,
|
||||
};
|
||||
}
|
||||
return { kind: 'file', src: driveStreamUrl(url) };
|
||||
}
|
||||
|
||||
// One video_url cell can hold several demos, one URL per line, the primary first; a legacy single-URL cell parses to a one-element list unchanged.
|
||||
export function parseVideoUrls(raw: string): string[] {
|
||||
return (raw || '')
|
||||
.split(/[\r\n]+/)
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Each entry keeps its original URL so callers can key and dedupe on it.
|
||||
export function videoEmbeds(raw: string): { url: string; embed: VideoEmbed }[] {
|
||||
const out: { url: string; embed: VideoEmbed }[] = [];
|
||||
for (const url of parseVideoUrls(raw)) {
|
||||
const embed = videoEmbed(url);
|
||||
if (embed) out.push({ url, embed });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const KIND_LABELS: Record<string, string> = {
|
||||
skill: 'Skill',
|
||||
workflow: 'Workflow',
|
||||
app: 'App',
|
||||
mode: 'Mode',
|
||||
agent: 'Agent',
|
||||
dashboard: 'Dashboard',
|
||||
bundle: 'Bundle',
|
||||
};
|
||||
|
||||
export function isBundle(listing: Listing): boolean {
|
||||
return (listing.kind || '').trim().toLowerCase() === 'bundle';
|
||||
}
|
||||
|
||||
export function parseBundleItems(raw: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const id of (raw || '').split(',').map((s) => s.trim()).filter(Boolean)) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Preserves bundle order, drops ids whose listing no longer exists (a member deleted after bundling), and never returns the bundle itself.
|
||||
export function resolveBundleMembers(bundle: Listing, all: Listing[]): Listing[] {
|
||||
const byId = new Map(all.map((l) => [l.id, l]));
|
||||
const out: Listing[] = [];
|
||||
for (const id of parseBundleItems(bundle.bundle_items)) {
|
||||
const member = byId.get(id);
|
||||
if (member && member.id !== bundle.id) out.push(member);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Link from '@mui/material/Link';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import Inventory2Icon from '@mui/icons-material/Inventory2';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { KIND_LABELS, parseTags, type Listing } from '../catalog';
|
||||
|
||||
interface Props {
|
||||
bundle: Listing | null;
|
||||
members: Listing[];
|
||||
onClose: () => void;
|
||||
onOpenMember: (member: Listing) => void;
|
||||
onInstallAll: () => void;
|
||||
onInstallMember: (id: string) => void;
|
||||
installing: boolean;
|
||||
}
|
||||
|
||||
// A bundle has no .swarm of its own, so the dialog installs every member at once and lists each one with its own install button and a click-through to its full detail.
|
||||
export default function PackageBundleDialog({
|
||||
bundle,
|
||||
members,
|
||||
onClose,
|
||||
onOpenMember,
|
||||
onInstallAll,
|
||||
onInstallMember,
|
||||
installing,
|
||||
}: Props) {
|
||||
const c = useClaudeTokens();
|
||||
if (!bundle) return null;
|
||||
|
||||
const installable = members.filter((m) => m.download_url);
|
||||
const tags = parseTags(bundle.tags);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
fullWidth
|
||||
maxWidth="md"
|
||||
scroll="paper"
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
borderRadius: { xs: 0, sm: 4 },
|
||||
m: { xs: 0, sm: 3 },
|
||||
maxHeight: { xs: '100%', sm: 'calc(100% - 48px)' },
|
||||
bgcolor: c.bg.surface,
|
||||
backgroundImage: 'none',
|
||||
boxShadow: c.shadow.lg,
|
||||
},
|
||||
},
|
||||
backdrop: { sx: { bgcolor: 'rgba(29, 29, 31, 0.42)', backdropFilter: 'blur(6px)' } },
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0 }}>
|
||||
<Box sx={{ position: 'sticky', top: 0, zIndex: 1, bgcolor: c.bg.surface, px: { xs: 2.5, sm: 4 }, py: 2 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.accent.primary }}>
|
||||
Marketplace bundle
|
||||
</Typography>
|
||||
<IconButton onClick={onClose} aria-label="Close bundle details" sx={{ color: c.text.tertiary }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: { xs: 2.5, sm: 5 }, pb: { xs: 4, sm: 5 } }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2.5} alignItems={{ xs: 'flex-start', sm: 'center' }} sx={{ mb: 3 }}>
|
||||
<Box sx={{ width: 76, height: 76, borderRadius: 3, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${c.accent.primary}1A`, overflow: 'hidden' }}>
|
||||
{bundle.icon_url ? (
|
||||
<Box component="img" src={bundle.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<Inventory2Icon sx={{ fontSize: 34, color: c.accent.primary }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: { xs: '1.65rem', sm: '2rem' }, lineHeight: 1.15, fontWeight: 700, letterSpacing: '-0.03em', color: c.text.primary }}>
|
||||
{bundle.title}
|
||||
</Typography>
|
||||
<Typography sx={{ mt: 0.75, color: c.accent.primary, fontWeight: 600 }}>
|
||||
Bundle · {members.length} {members.length === 1 ? 'package' : 'packages'}
|
||||
{bundle.author ? (
|
||||
<Box component="span" sx={{ color: c.text.tertiary, fontWeight: 400 }}>{` · ${bundle.author}`}</Box>
|
||||
) : null}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Typography sx={{ fontSize: '0.98rem', color: c.text.secondary, lineHeight: 1.7, whiteSpace: 'pre-wrap', mb: 3 }}>
|
||||
{bundle.description || 'A curated collection of packages.'}
|
||||
</Typography>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<Stack direction="row" sx={{ mb: 3, flexWrap: 'wrap', gap: 0.75 }}>
|
||||
{tags.map((tag) => (
|
||||
<Chip key={tag} label={`#${tag}`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.tertiary }} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Divider sx={{ borderColor: c.border.subtle, mb: 2.5 }} />
|
||||
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1.5, gap: 1.5, flexWrap: 'wrap' }}>
|
||||
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.muted }}>
|
||||
In this bundle
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={onInstallAll}
|
||||
disabled={installing || installable.length === 0}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disableElevation
|
||||
startIcon={
|
||||
installing ? (
|
||||
<CircularProgress size={15} thickness={5} sx={{ color: 'inherit' }} />
|
||||
) : (
|
||||
<DownloadIcon sx={{ fontSize: 17 }} />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
bgcolor: c.accent.primary,
|
||||
color: '#fff',
|
||||
px: 2,
|
||||
'&:hover': { bgcolor: c.accent.primary, filter: 'brightness(0.94)' },
|
||||
}}
|
||||
>
|
||||
Install all ({installable.length})
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{members.length === 0 ? (
|
||||
<Typography sx={{ color: c.text.tertiary, fontSize: '0.9rem' }}>
|
||||
The packages in this bundle are no longer available.
|
||||
</Typography>
|
||||
) : (
|
||||
<Stack spacing={1.25}>
|
||||
{members.map((m) => (
|
||||
<Box
|
||||
key={m.id}
|
||||
onClick={() => onOpenMember(m)}
|
||||
role="button"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.75,
|
||||
cursor: 'pointer',
|
||||
bgcolor: c.bg.elevated,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
borderRadius: 2.5,
|
||||
p: 1.75,
|
||||
transition: c.transition,
|
||||
'&:hover': { borderColor: c.border.medium, boxShadow: c.shadow.sm },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 44, height: 44, borderRadius: 2, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${c.accent.primary}14`, overflow: 'hidden' }}>
|
||||
{m.icon_url ? (
|
||||
<Box component="img" src={m.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<ExtensionIcon sx={{ fontSize: 22, color: c.accent.primary }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.95rem', fontWeight: 600, color: c.text.primary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{m.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted }}>
|
||||
{KIND_LABELS[m.kind] || m.kind || 'Package'}
|
||||
{m.version ? ` · v${m.version}` : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack spacing={0.5} alignItems="flex-end" sx={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInstallMember(m.id);
|
||||
}}
|
||||
disabled={installing || !m.download_url}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
textTransform: 'none',
|
||||
borderColor: c.border.medium,
|
||||
color: c.text.primary,
|
||||
'&:hover': { borderColor: c.accent.primary, bgcolor: `${c.accent.primary}0D` },
|
||||
}}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
{m.download_url && (
|
||||
<Link
|
||||
href={m.download_url}
|
||||
download
|
||||
rel="noopener"
|
||||
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => e.stopPropagation()}
|
||||
sx={{ fontSize: '0.72rem', color: c.text.muted, textDecorationColor: c.border.medium, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
Download .swarm file
|
||||
</Link>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Typography sx={{ mt: 3, fontSize: '0.8rem', color: c.text.ghost }}>
|
||||
Install all adds every package in this bundle at once, or install them one at a time. The raw .swarm files stay linked if you would rather keep a copy.
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import React, { useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
|
||||
import type { DetailBlock, DetailDoc } from '../notionDetails';
|
||||
|
||||
function Heading({ block, c }: { block: Extract<DetailBlock, { type: 'heading' }>; c: ClaudeTokens }) {
|
||||
return (
|
||||
<Typography
|
||||
component="h3"
|
||||
sx={{ mt: 3.5, mb: 1.25, fontSize: '1.05rem', fontWeight: 700, letterSpacing: '-0.02em', color: c.text.primary }}
|
||||
>
|
||||
{block.emoji ? `${block.emoji} ` : ''}
|
||||
{block.text}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function Paragraph({ block, c }: { block: Extract<DetailBlock, { type: 'paragraph' }>; c: ClaudeTokens }) {
|
||||
return (
|
||||
<Typography sx={{ fontSize: '0.95rem', lineHeight: 1.7, color: c.text.secondary, mb: 1.5 }}>
|
||||
{block.text}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
function Callout({ block, c }: { block: Extract<DetailBlock, { type: 'callout' }>; c: ClaudeTokens }) {
|
||||
const tone = block.tone ?? 'default';
|
||||
const bg =
|
||||
tone === 'warning' ? c.status.errorBg : tone === 'info' ? `${c.accent.primary}0F` : c.bg.secondary;
|
||||
const border =
|
||||
tone === 'warning' ? `${c.status.error}33` : tone === 'info' ? `${c.accent.primary}33` : c.border.subtle;
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1.5}
|
||||
sx={{ bgcolor: bg, border: `1px solid ${border}`, borderRadius: 2.5, p: 2, mb: 1.5 }}
|
||||
>
|
||||
{block.emoji && <Box sx={{ fontSize: '1.2rem', lineHeight: 1.4, flexShrink: 0 }}>{block.emoji}</Box>}
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
{block.title && (
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 700, color: c.text.primary, mb: 0.25 }}>
|
||||
{block.title}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography sx={{ fontSize: '0.92rem', lineHeight: 1.65, color: c.text.secondary }}>
|
||||
{block.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Features({ block, c }: { block: Extract<DetailBlock, { type: 'features' }>; c: ClaudeTokens }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
|
||||
gap: 1.25,
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
{block.items.map((item) => (
|
||||
<Stack
|
||||
key={item.title}
|
||||
direction="row"
|
||||
spacing={1.5}
|
||||
sx={{ bgcolor: c.bg.elevated, border: `1px solid ${c.border.subtle}`, borderRadius: 2.5, p: 1.75 }}
|
||||
>
|
||||
<Box sx={{ fontSize: '1.25rem', lineHeight: 1.3, flexShrink: 0 }}>{item.emoji}</Box>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.9rem', fontWeight: 700, color: c.text.primary, mb: 0.25 }}>
|
||||
{item.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', lineHeight: 1.55, color: c.text.tertiary }}>
|
||||
{item.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Steps({ block, c }: { block: Extract<DetailBlock, { type: 'steps' }>; c: ClaudeTokens }) {
|
||||
return (
|
||||
<Stack spacing={1.25} sx={{ mb: 1.5 }}>
|
||||
{block.items.map((text, i) => (
|
||||
<Stack key={i} direction="row" spacing={1.5} alignItems="flex-start">
|
||||
<Box
|
||||
sx={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
flexShrink: 0,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: `${c.accent.primary}14`,
|
||||
color: c.accent.primary,
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.92rem', lineHeight: 1.6, color: c.text.secondary, pt: 0.25 }}>
|
||||
{text}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function Bullets({ block, c }: { block: Extract<DetailBlock, { type: 'bullets' }>; c: ClaudeTokens }) {
|
||||
return (
|
||||
<Stack spacing={0.75} sx={{ mb: 1.5 }}>
|
||||
{block.items.map((text, i) => (
|
||||
<Stack key={i} direction="row" spacing={1.25} alignItems="flex-start">
|
||||
<Box sx={{ height: 'calc(0.92rem * 1.6)', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<Box sx={{ width: 5, height: 5, borderRadius: '50%', bgcolor: c.text.muted }} />
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.92rem', lineHeight: 1.6, color: c.text.secondary }}>{text}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqItem({ q, a, c }: { q: string; a: string; c: ClaudeTokens }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Box sx={{ border: `1px solid ${c.border.subtle}`, borderRadius: 2.5, overflow: 'hidden' }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
role="button"
|
||||
aria-expanded={open}
|
||||
sx={{ cursor: 'pointer', px: 2, py: 1.5, bgcolor: c.bg.elevated }}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>💡 {q}</Typography>
|
||||
<ExpandMoreIcon
|
||||
sx={{ color: c.text.muted, transform: open ? 'rotate(180deg)' : 'none', transition: c.transition }}
|
||||
/>
|
||||
</Stack>
|
||||
<Collapse in={open}>
|
||||
<Typography sx={{ px: 2, py: 1.75, fontSize: '0.9rem', lineHeight: 1.65, color: c.text.secondary }}>
|
||||
{a}
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Faq({ block, c }: { block: Extract<DetailBlock, { type: 'faq' }>; c: ClaudeTokens }) {
|
||||
return (
|
||||
<Stack spacing={1} sx={{ mb: 1.5 }}>
|
||||
{block.items.map((item, i) => (
|
||||
<FaqItem key={i} q={item.q} a={item.a} c={c} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function renderBlock(block: DetailBlock, i: number, c: ClaudeTokens) {
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
return <Heading key={i} block={block} c={c} />;
|
||||
case 'paragraph':
|
||||
return <Paragraph key={i} block={block} c={c} />;
|
||||
case 'callout':
|
||||
return <Callout key={i} block={block} c={c} />;
|
||||
case 'features':
|
||||
return <Features key={i} block={block} c={c} />;
|
||||
case 'steps':
|
||||
return <Steps key={i} block={block} c={c} />;
|
||||
case 'bullets':
|
||||
return <Bullets key={i} block={block} c={c} />;
|
||||
case 'faq':
|
||||
return <Faq key={i} block={block} c={c} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function PackageDetails({ doc }: { doc: DetailDoc }) {
|
||||
const c = useClaudeTokens();
|
||||
return <Box>{doc.blocks.map((block, i) => renderBlock(block, i, c))}</Box>;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Link from '@mui/material/Link';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { KIND_LABELS, parseTags, type Listing } from '../catalog';
|
||||
import { detailsForListing } from '../notionDetails';
|
||||
import PackageDetails from './PackageDetails';
|
||||
import PackageVideoSection from './PackageVideoSection';
|
||||
|
||||
interface Props {
|
||||
listing: Listing | null;
|
||||
onClose: () => void;
|
||||
onInstall: () => void;
|
||||
installing: boolean;
|
||||
installed?: boolean;
|
||||
}
|
||||
|
||||
export default function PackageDialog({ listing, onClose, onInstall, installing, installed }: Props) {
|
||||
const c = useClaudeTokens();
|
||||
if (!listing) return null;
|
||||
|
||||
const details = detailsForListing(listing);
|
||||
const tags = parseTags(listing.tags);
|
||||
|
||||
const subMeta = [
|
||||
KIND_LABELS[listing.kind] || listing.kind || 'Package',
|
||||
listing.version ? `v${listing.version}` : '',
|
||||
listing.author || '',
|
||||
listing.size || '',
|
||||
listing.updated_at ? `Updated ${listing.updated_at}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
fullWidth
|
||||
maxWidth="md"
|
||||
scroll="paper"
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
borderRadius: { xs: 0, sm: 4 },
|
||||
m: { xs: 0, sm: 3 },
|
||||
maxHeight: { xs: '100%', sm: 'calc(100% - 48px)' },
|
||||
bgcolor: c.bg.surface,
|
||||
backgroundImage: 'none',
|
||||
boxShadow: c.shadow.lg,
|
||||
},
|
||||
},
|
||||
backdrop: { sx: { bgcolor: 'rgba(29, 29, 31, 0.42)', backdropFilter: 'blur(6px)' } },
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ p: 0 }}>
|
||||
<Box sx={{ position: 'sticky', top: 0, zIndex: 1, bgcolor: c.bg.surface, px: { xs: 2.5, sm: 4 }, py: 2 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.muted }}>
|
||||
Marketplace package
|
||||
</Typography>
|
||||
<IconButton onClick={onClose} aria-label="Close package details" sx={{ color: c.text.tertiary }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: { xs: 2.5, sm: 5 }, pb: { xs: 4, sm: 5 } }}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2.5} alignItems={{ xs: 'flex-start', sm: 'center' }} sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ width: 76, height: 76, borderRadius: 3, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${c.accent.primary}14`, overflow: 'hidden' }}>
|
||||
{listing.icon_url ? (
|
||||
<Box component="img" src={listing.icon_url} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<ExtensionIcon sx={{ fontSize: 34, color: c.accent.primary }} />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: { xs: '1.65rem', sm: '2rem' }, lineHeight: 1.15, fontWeight: 700, letterSpacing: '-0.03em', color: c.text.primary }}>
|
||||
{listing.title}
|
||||
</Typography>
|
||||
<Typography sx={{ mt: 0.75, color: c.text.tertiary, fontSize: '0.9rem' }}>{subMeta}</Typography>
|
||||
{tags.length > 0 && (
|
||||
<Stack direction="row" sx={{ mt: 1.25, flexWrap: 'wrap', gap: 0.75 }}>
|
||||
{tags.map((tag) => (
|
||||
<Chip key={tag} label={`#${tag}`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.tertiary }} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
<Stack spacing={0.75} alignItems={{ xs: 'flex-start', sm: 'flex-end' }} sx={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
onClick={onInstall}
|
||||
variant="contained"
|
||||
disableElevation
|
||||
disabled={installing || !!installed || !listing.download_url}
|
||||
startIcon={
|
||||
installing ? (
|
||||
<CircularProgress size={16} thickness={5} sx={{ color: 'inherit' }} />
|
||||
) : installed ? (
|
||||
<CheckRoundedIcon />
|
||||
) : (
|
||||
<DownloadIcon />
|
||||
)
|
||||
}
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
px: 3,
|
||||
py: 1.15,
|
||||
whiteSpace: 'nowrap',
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
bgcolor: c.accent.primary,
|
||||
'&:hover': { bgcolor: c.accent.hover },
|
||||
}}
|
||||
>
|
||||
{installed ? 'Installed' : 'Install'}
|
||||
</Button>
|
||||
{listing.download_url && (
|
||||
<Link
|
||||
href={listing.download_url}
|
||||
download
|
||||
rel="noopener"
|
||||
sx={{ fontSize: '0.78rem', color: c.text.muted, textDecorationColor: c.border.medium, '&:hover': { color: c.text.primary } }}
|
||||
>
|
||||
Download .swarm file
|
||||
</Link>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{listing.description && (
|
||||
<Typography sx={{ fontSize: '0.98rem', color: c.text.secondary, lineHeight: 1.7, whiteSpace: 'pre-wrap', mb: 3 }}>
|
||||
{listing.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<PackageVideoSection key={listing.id} raw={listing.video_url} />
|
||||
|
||||
{details && (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<PackageDetails doc={details} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
|
||||
import MovieIcon from '@mui/icons-material/Movie';
|
||||
import PlayArrowRounded from '@mui/icons-material/PlayArrowRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { videoEmbeds, type VideoEmbed } from '../catalog';
|
||||
import YouTubePlayer from './YouTubePlayer';
|
||||
|
||||
// The visible band of the cropped YouTube player, and how far it is slid up to hide the letterboxed top.
|
||||
const YT_WINDOW_HEIGHT = 500;
|
||||
const YT_TOP_OFFSET = -160;
|
||||
|
||||
// maxresdefault is missing for some uploads, so fall back to the size every video has.
|
||||
function fallbackThumb(e: React.SyntheticEvent<HTMLImageElement>) {
|
||||
const img = e.currentTarget;
|
||||
if (img.src.includes('maxresdefault')) img.src = img.src.replace('maxresdefault', 'hqdefault');
|
||||
}
|
||||
|
||||
// A facade player: at rest a clean poster plus our own play button, so the resting state matches the rest of the UI.
|
||||
function DemoVideo({ embed }: { embed: VideoEmbed }) {
|
||||
const c = useClaudeTokens();
|
||||
const [playing, setPlaying] = useState(false);
|
||||
|
||||
const frame = {
|
||||
position: 'relative' as const,
|
||||
width: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
mb: 3,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
bgcolor: '#000',
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
};
|
||||
|
||||
if (embed.kind === 'file') {
|
||||
return (
|
||||
<Box sx={frame}>
|
||||
<Box
|
||||
component="video"
|
||||
src={embed.src}
|
||||
controls
|
||||
preload="metadata"
|
||||
sx={{ position: 'absolute', inset: 0, width: '100%', height: '100%', border: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// The overflow crop is the only thing that removes YouTube's edge-anchored chrome from the square player.
|
||||
if (playing && embed.videoId) {
|
||||
return (
|
||||
<Box sx={{ height: YT_WINDOW_HEIGHT, borderRadius: 3, overflow: 'hidden' }}>
|
||||
<Box sx={{ mt: `${YT_TOP_OFFSET}px` }}>
|
||||
<YouTubePlayer videoId={embed.videoId} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => setPlaying(true)}
|
||||
role="button"
|
||||
aria-label="Play demo video"
|
||||
sx={{ ...frame, cursor: 'pointer', '&:hover .demo-play': { transform: 'translate(-50%, -50%) scale(1.06)' } }}
|
||||
>
|
||||
{embed.poster && (
|
||||
<Box
|
||||
component="img"
|
||||
src={embed.poster}
|
||||
alt=""
|
||||
onError={fallbackThumb}
|
||||
sx={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
)}
|
||||
<Box sx={{ position: 'absolute', inset: 0, bgcolor: 'rgba(0,0,0,0.18)' }} />
|
||||
<Box
|
||||
className="demo-play"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 68,
|
||||
height: 68,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(255,255,255,0.92)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
boxShadow: c.shadow.lg,
|
||||
transition: c.transition,
|
||||
}}
|
||||
>
|
||||
<PlayArrowRounded sx={{ fontSize: 40, color: c.text.primary, ml: 0.5 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function UpNextThumb({
|
||||
item,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
item: { url: string; embed: VideoEmbed };
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<Box
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
aria-label="Play this video"
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: { xs: 140, sm: '100%' },
|
||||
flexShrink: 0,
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
bgcolor: '#000',
|
||||
border: `2px solid ${active ? c.accent.primary : c.border.subtle}`,
|
||||
boxShadow: active ? `0 0 0 3px ${c.accent.primary}33` : 'none',
|
||||
transition: c.transition,
|
||||
opacity: active ? 1 : 0.82,
|
||||
'&:hover': { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
{item.embed.poster ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={item.embed.poster}
|
||||
alt=""
|
||||
onError={fallbackThumb}
|
||||
sx={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<Box sx={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<MovieIcon sx={{ fontSize: 28, color: 'rgba(255,255,255,0.6)' }} />
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ position: 'absolute', inset: 0, bgcolor: active ? 'rgba(0,0,0,0.28)' : 'rgba(0,0,0,0.12)' }} />
|
||||
{active && (
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={0.5}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 6,
|
||||
left: 6,
|
||||
px: 0.75,
|
||||
py: 0.25,
|
||||
borderRadius: 999,
|
||||
bgcolor: c.accent.primary,
|
||||
boxShadow: c.shadow.sm,
|
||||
}}
|
||||
>
|
||||
<GraphicEqIcon sx={{ fontSize: 13, color: '#fff' }} />
|
||||
<Typography sx={{ fontSize: '0.62rem', fontWeight: 700, color: '#fff', lineHeight: 1, letterSpacing: '0.01em' }}>
|
||||
Now playing
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Keying DemoVideo on the active URL remounts it, so switching clips starts the new one cleanly.
|
||||
export default function PackageVideoSection({ raw }: { raw: string }) {
|
||||
const c = useClaudeTokens();
|
||||
const videos = useMemo(() => videoEmbeds(raw), [raw]);
|
||||
const [activeUrl, setActiveUrl] = useState(videos[0]?.url ?? '');
|
||||
if (videos.length === 0) return null;
|
||||
const active = videos.find((v) => v.url === activeUrl) ?? videos[0];
|
||||
const hasUpNext = videos.length > 1;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: hasUpNext ? 'row' : 'column' }}
|
||||
spacing={hasUpNext ? 2 : 0}
|
||||
alignItems="flex-start"
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 0, width: '100%', '& > *': { mb: '0 !important' } }}>
|
||||
<DemoVideo key={active.url} embed={active.embed} />
|
||||
</Box>
|
||||
{hasUpNext && (
|
||||
<Box sx={{ width: { xs: '100%', sm: 118 }, flexShrink: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.82rem', fontWeight: 600, color: c.text.muted, mb: 1 }}>
|
||||
Up next
|
||||
</Typography>
|
||||
<Stack
|
||||
direction={{ xs: 'row', sm: 'column' }}
|
||||
sx={{ gap: 1.25, overflowX: { xs: 'auto', sm: 'visible' }, pb: 0.5 }}
|
||||
>
|
||||
{videos.map((v) => (
|
||||
<UpNextThumb key={v.url} item={v} active={v.url === active.url} onClick={() => setActiveUrl(v.url)} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import PlayArrowRounded from '@mui/icons-material/PlayArrowRounded';
|
||||
import PauseRounded from '@mui/icons-material/PauseRounded';
|
||||
import VolumeUpRounded from '@mui/icons-material/VolumeUpRounded';
|
||||
import VolumeOffRounded from '@mui/icons-material/VolumeOffRounded';
|
||||
import FullscreenRounded from '@mui/icons-material/FullscreenRounded';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface YouTubePlayerApi {
|
||||
playVideo: () => void;
|
||||
pauseVideo: () => void;
|
||||
mute: () => void;
|
||||
unMute: () => void;
|
||||
seekTo: (seconds: number, allowSeekAhead: boolean) => void;
|
||||
getCurrentTime: () => number;
|
||||
getDuration: () => number;
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
interface YouTubeReadyEvent {
|
||||
target: YouTubePlayerApi;
|
||||
}
|
||||
|
||||
interface YouTubeStateEvent {
|
||||
target: YouTubePlayerApi;
|
||||
data: number;
|
||||
}
|
||||
|
||||
interface YouTubeApi {
|
||||
Player: new (
|
||||
host: HTMLElement,
|
||||
options: {
|
||||
videoId: string;
|
||||
playerVars: Record<string, number>;
|
||||
events: {
|
||||
onReady: (e: YouTubeReadyEvent) => void;
|
||||
onStateChange: (e: YouTubeStateEvent) => void;
|
||||
};
|
||||
},
|
||||
) => YouTubePlayerApi;
|
||||
PlayerState: { PLAYING: number };
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
YT?: YouTubeApi;
|
||||
onYouTubeIframeAPIReady?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
let apiPromise: Promise<void> | null = null;
|
||||
|
||||
// The iframe API script is global and answers through one window callback, so every player shares a single load.
|
||||
function loadApi(): Promise<void> {
|
||||
if (window.YT?.Player) return Promise.resolve();
|
||||
if (apiPromise) return apiPromise;
|
||||
apiPromise = new Promise((resolve) => {
|
||||
const prev = window.onYouTubeIframeAPIReady;
|
||||
window.onYouTubeIframeAPIReady = () => {
|
||||
prev?.();
|
||||
resolve();
|
||||
};
|
||||
const tag = document.createElement('script');
|
||||
tag.src = 'https://www.youtube.com/iframe_api';
|
||||
document.head.appendChild(tag);
|
||||
});
|
||||
return apiPromise;
|
||||
}
|
||||
|
||||
function fmt(seconds: number): string {
|
||||
const t = Number.isFinite(seconds) && seconds > 0 ? seconds : 0;
|
||||
const m = Math.floor(t / 60);
|
||||
const s = Math.floor(t % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function YouTubePlayer({ videoId }: { videoId: string }) {
|
||||
const c = useClaudeTokens();
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const playerRef = useRef<YouTubePlayerApi | null>(null);
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [cur, setCur] = useState(0);
|
||||
const [dur, setDur] = useState(0);
|
||||
const [hover, setHover] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
void loadApi().then(() => {
|
||||
const api = window.YT;
|
||||
if (disposed || !api || !hostRef.current) return;
|
||||
playerRef.current = new api.Player(hostRef.current, {
|
||||
videoId,
|
||||
playerVars: {
|
||||
autoplay: 1,
|
||||
controls: 0,
|
||||
modestbranding: 1,
|
||||
rel: 0,
|
||||
fs: 0,
|
||||
disablekb: 1,
|
||||
iv_load_policy: 3,
|
||||
playsinline: 1,
|
||||
},
|
||||
events: {
|
||||
onReady: (e) => {
|
||||
setReady(true);
|
||||
setDur(e.target.getDuration() || 0);
|
||||
e.target.playVideo();
|
||||
},
|
||||
onStateChange: (e) => {
|
||||
const playingNow = e.data === api.PlayerState.PLAYING;
|
||||
setPlaying(playingNow);
|
||||
if (playingNow) setDur(e.target.getDuration() || 0);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
try {
|
||||
playerRef.current?.destroy();
|
||||
} catch {
|
||||
// A player torn down with its iframe already gone throws on destroy; nothing left to clean up.
|
||||
}
|
||||
};
|
||||
}, [videoId]);
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const p = playerRef.current;
|
||||
if (p?.getCurrentTime) setCur(p.getCurrentTime() || 0);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const togglePlay = () => {
|
||||
const p = playerRef.current;
|
||||
if (!p) return;
|
||||
if (playing) p.pauseVideo();
|
||||
else p.playVideo();
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const p = playerRef.current;
|
||||
if (!p) return;
|
||||
if (muted) {
|
||||
p.unMute();
|
||||
setMuted(false);
|
||||
} else {
|
||||
p.mute();
|
||||
setMuted(true);
|
||||
}
|
||||
};
|
||||
|
||||
const seek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const p = playerRef.current;
|
||||
if (!p || !dur) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||
p.seekTo(frac * dur, true);
|
||||
setCur(frac * dur);
|
||||
};
|
||||
|
||||
const goFullscreen = () => {
|
||||
const el = hostRef.current?.parentElement;
|
||||
el?.requestFullscreen?.();
|
||||
};
|
||||
|
||||
// Square frame on purpose: YouTube anchors its title and control chrome to the player box, so a box taller than 16:9 letterboxes the video and lands that chrome on the black bands the caller crops away.
|
||||
const frame = {
|
||||
position: 'relative' as const,
|
||||
width: '100%',
|
||||
aspectRatio: '1 / 1',
|
||||
mb: 3,
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden',
|
||||
bgcolor: '#000',
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={frame} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
|
||||
<Box
|
||||
ref={hostRef}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
'& iframe': { width: '100%', height: '100%', border: 0, display: 'block' },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Swallows every hover and click so YouTube never paints its own chrome. */}
|
||||
<Box onClick={togglePlay} sx={{ position: 'absolute', inset: 0, cursor: 'pointer', zIndex: 2 }} />
|
||||
|
||||
{/* Opaque while paused, hiding YouTube's title, share and watch-later overlay behind our own play button. */}
|
||||
<Box
|
||||
onClick={togglePlay}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
bgcolor: '#000',
|
||||
opacity: ready && playing ? 0 : 1,
|
||||
pointerEvents: ready && playing ? 'none' : 'auto',
|
||||
transition: 'opacity 160ms ease',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 68,
|
||||
height: 68,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(255,255,255,0.92)',
|
||||
boxShadow: c.shadow.lg,
|
||||
}}
|
||||
>
|
||||
<PlayArrowRounded sx={{ fontSize: 40, color: '#111', ml: 0.5 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 5,
|
||||
px: 1.5,
|
||||
pt: 3,
|
||||
pb: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0.5,
|
||||
background: 'linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0) 100%)',
|
||||
opacity: playing && hover ? 1 : 0,
|
||||
pointerEvents: playing && hover ? 'auto' : 'none',
|
||||
transition: 'opacity 160ms ease',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onClick={seek}
|
||||
sx={{ position: 'relative', height: 4, borderRadius: 2, bgcolor: 'rgba(255,255,255,0.28)', cursor: 'pointer', mb: 0.5 }}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: `${dur ? (cur / dur) * 100 : 0}%`,
|
||||
bgcolor: '#fff',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IconButton onClick={togglePlay} size="small" sx={{ color: '#fff' }}>
|
||||
{playing ? <PauseRounded /> : <PlayArrowRounded />}
|
||||
</IconButton>
|
||||
<IconButton onClick={toggleMute} size="small" sx={{ color: '#fff' }}>
|
||||
{muted ? <VolumeOffRounded /> : <VolumeUpRounded />}
|
||||
</IconButton>
|
||||
<Box sx={{ fontSize: '0.78rem', color: '#fff', fontVariantNumeric: 'tabular-nums', ml: 0.5 }}>
|
||||
{fmt(cur)} / {fmt(dur)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton onClick={goFullscreen} size="small" sx={{ color: '#fff' }}>
|
||||
<FullscreenRounded />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { ImportPreflight } from '@/app/components/share/shareTypes';
|
||||
|
||||
// Installing a marketplace package is the ordinary bundle import with the download done for you:
|
||||
// the backend fetches the .swarm and stages it, then the SAME confirm surface and commit route a
|
||||
// dropped file uses take over. One door, so the secret review and the skill-confirm rule cannot
|
||||
// hold on one path and not the other.
|
||||
export async function stagePackageInstall(listingId: string): Promise<ImportPreflight> {
|
||||
const res = await fetch(`${API_BASE}/marketplace/install/preflight`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: listingId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = "We couldn't download this package.";
|
||||
try {
|
||||
const body = (await res.json()) as { detail?: string };
|
||||
if (body.detail) detail = body.detail;
|
||||
} catch {
|
||||
// A non-JSON error body is still an error; the default sentence stands.
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Listing } from './catalog';
|
||||
|
||||
// A package's long-form writeup is authored in Notion; we model it as structured blocks and render it natively rather than iframing Notion, which would drag in its own fonts, chrome and network load with no theming.
|
||||
|
||||
export type DetailBlock =
|
||||
| { type: 'heading'; emoji?: string; text: string }
|
||||
| { type: 'paragraph'; text: string }
|
||||
| { type: 'callout'; emoji?: string; title?: string; text: string; tone?: 'default' | 'warning' | 'info' }
|
||||
| { type: 'features'; items: { emoji: string; title: string; text: string }[] }
|
||||
| { type: 'steps'; items: string[] }
|
||||
| { type: 'bullets'; items: string[] }
|
||||
| { type: 'faq'; items: { q: string; a: string }[] };
|
||||
|
||||
export interface DetailDoc {
|
||||
title: string;
|
||||
meta?: string;
|
||||
tagline?: string;
|
||||
tags?: string[];
|
||||
blocks: DetailBlock[];
|
||||
}
|
||||
|
||||
// Null when the listing has no linked page or the stored JSON is malformed, and the dialog then renders nothing below the video.
|
||||
export function detailsForListing(listing: Listing): DetailDoc | null {
|
||||
const raw = (listing.details_json || '').trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isDetailDoc(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// details_json arrives from a spreadsheet cell, so the shape is validated at this boundary; per-block fields are not, because PackageDetails already skips block types it does not recognize.
|
||||
function isDetailDoc(v: unknown): v is DetailDoc {
|
||||
if (!v || typeof v !== 'object') return false;
|
||||
const doc = v as Record<string, unknown>;
|
||||
if (typeof doc.title !== 'string') return false;
|
||||
if (!Array.isArray(doc.blocks)) return false;
|
||||
return doc.blocks.every(
|
||||
(b) => b && typeof b === 'object' && typeof (b as Record<string, unknown>).type === 'string',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import type { Listing } from '@/app/pages/Directory/packages/catalog';
|
||||
|
||||
const MARKETPLACE_API = `${API_BASE}/marketplace`;
|
||||
|
||||
// 'cache' means the sheet was unreachable and these are the last listings we saw, which the tab says out loud.
|
||||
export type CatalogSource = 'sheet' | 'cache' | 'empty';
|
||||
|
||||
interface CatalogState {
|
||||
listings: Listing[];
|
||||
source: CatalogSource;
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const initialState: CatalogState = { listings: [], source: 'empty', loading: false, loaded: false, error: '' };
|
||||
|
||||
interface CatalogPayload {
|
||||
source: CatalogSource;
|
||||
count: number;
|
||||
listings: Listing[];
|
||||
error: string;
|
||||
}
|
||||
|
||||
export const fetchMarketplaceListings = createAsyncThunk(
|
||||
'marketplaceCatalog/fetch',
|
||||
async (refresh: boolean = false) => {
|
||||
const res = await fetch(`${MARKETPLACE_API}/listings${refresh ? '?refresh=true' : ''}`);
|
||||
if (!res.ok) throw new Error(`Marketplace listings failed: ${res.status}`);
|
||||
return (await res.json()) as CatalogPayload;
|
||||
},
|
||||
{ condition: (_, { getState }) => !(getState() as { marketplaceCatalog: CatalogState }).marketplaceCatalog.loading },
|
||||
);
|
||||
|
||||
const marketplaceCatalogSlice = createSlice({
|
||||
name: 'marketplaceCatalog',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchMarketplaceListings.pending, (state) => {
|
||||
state.loading = true;
|
||||
})
|
||||
.addCase(fetchMarketplaceListings.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.loaded = true;
|
||||
state.listings = action.payload.listings;
|
||||
state.source = action.payload.source;
|
||||
state.error = action.payload.error;
|
||||
})
|
||||
.addCase(fetchMarketplaceListings.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.loaded = true;
|
||||
// Keep whatever listings we already had: a failed refresh must not empty a populated store.
|
||||
state.error = action.error.message || 'Could not reach the marketplace.';
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default marketplaceCatalogSlice.reducer;
|
||||
@@ -8,6 +8,7 @@ import modesReducer from './modesSlice';
|
||||
import settingsReducer from './settingsSlice';
|
||||
import mcpRegistryReducer from './mcpRegistrySlice';
|
||||
import skillRegistryReducer from './skillRegistrySlice';
|
||||
import marketplaceCatalogReducer from './marketplaceCatalogSlice';
|
||||
import outputsReducer from './outputsSlice';
|
||||
import dashboardLayoutReducer from './dashboardLayoutSlice';
|
||||
import dashboardsReducer from './dashboardsSlice';
|
||||
@@ -31,6 +32,7 @@ export const store = configureStore({
|
||||
settings: settingsReducer,
|
||||
mcpRegistry: mcpRegistryReducer,
|
||||
skillRegistry: skillRegistryReducer,
|
||||
marketplaceCatalog: marketplaceCatalogReducer,
|
||||
outputs: outputsReducer,
|
||||
dashboardLayout: dashboardLayoutReducer,
|
||||
dashboards: dashboardsReducer,
|
||||
|
||||
Reference in New Issue
Block a user