diff --git a/README.md b/README.md index 1f6566a..6bd7d26 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,17 @@ Add your public URL + /mcp to ChatGPT's `"Settings > Connectors"` . fastapps create additional-widget ``` +### Using Widget Templates + +FastApps provides pre-built templates to jumpstart your widget development: + +```bash +# Create widget from a template +fastapps create my-list --list # Vertical list with items +fastapps create my-carousel --carousel # Horizontal scrolling cards +fastapps create my-albums --albums # Photo gallery viewer +``` + ### Editing Your Widget diff --git a/fastapps/__init__.py b/fastapps/__init__.py index 5e05efa..737f3e1 100644 --- a/fastapps/__init__.py +++ b/fastapps/__init__.py @@ -49,13 +49,4 @@ async def execute(self, input_data) -> Dict[str, Any]: "WidgetBuildResult", "Field", "ConfigDict", - # Dev server API - "start_dev_server", - "start_dev_server_with_config", - "get_server_info", - "run_dev_server", - "DevServerConfig", - "ServerInfo", - "DevServerError", - "ProjectNotFoundError", ] + _auth_exports diff --git a/fastapps/builder/build-all.mts b/fastapps/builder/build-all.mts index 0ad62d4..1b7b1ea 100644 --- a/fastapps/builder/build-all.mts +++ b/fastapps/builder/build-all.mts @@ -9,6 +9,16 @@ import crypto from "crypto"; const pkgPath = path.join(process.cwd(), "package.json"); const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); +// Auto-detect and import Tailwind CSS if available +let tailwindcss: any = null; +try { + const tailwindModule = await import("@tailwindcss/vite"); + tailwindcss = tailwindModule.default; + console.log("✓ Tailwind CSS detected"); +} catch (e) { + // Tailwind not installed, skip +} + // Find all widget directories with index.{tsx,jsx} const widgetDirs = fg.sync("widgets/*/", { onlyDirectories: true }); const entries = widgetDirs.map((dir) => { @@ -18,10 +28,20 @@ const entries = widgetDirs.map((dir) => { }).filter(Boolean); const outDir = "assets"; +// Determine build mode: 'hosted' (default) or 'inline' +const modeEnv = (process.env.MODE ?? "").toLowerCase(); +const MODE: "hosted" | "inline" = modeEnv === "inline" ? "inline" : "hosted"; + +// Global CSS that applies to all widgets +const GLOBAL_CSS_PATH = path.resolve("widgets/index.css"); +const PER_ENTRY_CSS_GLOB = "**/*.{css,pcss,scss,sass}"; +const PER_ENTRY_CSS_IGNORE = ["**/*.module.*"]; + function wrapEntryPlugin( virtualId: string, entryFile: string, - widgetName: string + widgetName: string, + cssPaths: string[] ): Plugin { return { name: `virtual-entry-wrapper:${entryFile}`, @@ -33,8 +53,14 @@ function wrapEntryPlugin( return null; } + // Import CSS files (global first, then per-entry) + const cssImports = cssPaths + .map((css) => `import ${JSON.stringify(css)};`) + .join("\n"); + // Automatically add mounting logic - no _app.jsx needed! return ` + ${cssImports} import React from 'react'; import { createRoot } from 'react-dom/client'; import Component from ${JSON.stringify(entryFile)}; @@ -61,13 +87,32 @@ for (const file of entries) { const name = path.basename(path.dirname(file)); const entryAbs = path.resolve(file); + const entryDir = path.dirname(entryAbs); + + // Collect CSS paths: global first, then per-entry + const cssPaths: string[] = []; + + // Add global CSS if it exists + if (fs.existsSync(GLOBAL_CSS_PATH)) { + cssPaths.push(GLOBAL_CSS_PATH); + } + + // Add per-entry CSS + const perEntryCss = fg.sync(PER_ENTRY_CSS_GLOB, { + cwd: entryDir, + absolute: true, + dot: false, + ignore: PER_ENTRY_CSS_IGNORE, + }); + cssPaths.push(...perEntryCss); const virtualId = `\0virtual-entry:${entryAbs}`; const createConfig = (): InlineConfig => ({ plugins: [ - wrapEntryPlugin(virtualId, entryAbs, name), + wrapEntryPlugin(virtualId, entryAbs, name, cssPaths), react(), + ...(tailwindcss ? [tailwindcss()] : []), { name: "remove-manual-chunks", outputOptions(options) { @@ -143,32 +188,56 @@ console.groupEnd(); console.log("new hash: ", h); -for (const name of builtNames) { - const dir = outDir; - const htmlPath = path.join(dir, `${name}-${h}.html`); - const cssPath = path.join(dir, `${name}-${h}.css`); - const jsPath = path.join(dir, `${name}-${h}.js`); - - const css = fs.existsSync(cssPath) - ? fs.readFileSync(cssPath, { encoding: "utf8" }) - : ""; - const js = fs.existsSync(jsPath) - ? fs.readFileSync(jsPath, { encoding: "utf8" }) - : ""; - - const cssBlock = css ? `\n \n` : ""; - const jsBlock = js ? `\n ` : ""; - - const html = [ - "", - "", - `${cssBlock}`, - "", - `
${jsBlock}`, - "", - "", - ].join("\n"); - fs.writeFileSync(htmlPath, html, { encoding: "utf8" }); - console.log(`${htmlPath} (generated)`); +if (MODE === "inline") { + for (const name of builtNames) { + const dir = outDir; + const htmlPath = path.join(dir, `${name}-${h}.html`); + const cssPath = path.join(dir, `${name}-${h}.css`); + const jsPath = path.join(dir, `${name}-${h}.js`); + + const css = fs.existsSync(cssPath) + ? fs.readFileSync(cssPath, { encoding: "utf8" }) + : ""; + const js = fs.existsSync(jsPath) + ? fs.readFileSync(jsPath, { encoding: "utf8" }) + : ""; + + const cssBlock = css ? `\n \n` : ""; + const jsBlock = js ? `\n ` : ""; + + const html = [ + "", + "", + `${cssBlock}`, + "", + `
${jsBlock}`, + "", + "", + ].join("\n"); + fs.writeFileSync(htmlPath, html, { encoding: "utf8" }); + console.log(`${htmlPath} (generated inline)`); + } +} else { + const defaultBaseUrl = "http://localhost:4444"; + const baseUrlCandidate = process.env.BASE_URL?.trim() ?? ""; + const baseUrlRaw = baseUrlCandidate || defaultBaseUrl; + const normalizedBaseUrl = baseUrlRaw.replace(/\/+$/, ""); + console.log(`Using BASE_URL: ${normalizedBaseUrl}`); + for (const name of builtNames) { + const dir = outDir; + const htmlPath = path.join(dir, `${name}-${h}.html`); + const html = ` + + + + + + +
+ + +`; + fs.writeFileSync(htmlPath, html, { encoding: "utf8" }); + console.log(`${htmlPath} (generated with external references)`); + } } - diff --git a/fastapps/builder/compiler.py b/fastapps/builder/compiler.py index a82c526..b132a34 100644 --- a/fastapps/builder/compiler.py +++ b/fastapps/builder/compiler.py @@ -1,3 +1,4 @@ +import os import platform import re import shutil @@ -31,23 +32,39 @@ def __init__(self, project_root: Path | str): self.widgets_dir = self.project_root / "widgets" self.framework_dir = Path(__file__).parent - def build_all(self) -> Dict[str, WidgetBuildResult]: + def build_all(self, mode: str = "hosted") -> Dict[str, WidgetBuildResult]: """ Build all widgets in the project. + Args: + mode: Build mode - "hosted" (default, external JS/CSS references) or + "inline" (self-contained HTML) + Returns: Dictionary mapping widget names to build results. """ # 1. Auto-discover widgets self._discover_widgets() - # 2. Copy framework's build script to project (if not exists) + # 2. Ensure unified build script exists in project (if not exists) self._ensure_build_script() # 3. Run build (Windows-compatible) npx_cmd = "npx.cmd" if platform.system() == "Windows" else "npx" + build_script = "build-all.mts" + + # Pass mode and BASE_URL (for hosted) via environment + env = os.environ.copy() + # Explicit mode for the script + env["MODE"] = mode + if mode == "hosted": + env["BASE_URL"] = env.get("BASE_URL", "http://localhost:4444") + subprocess.run( - [npx_cmd, "tsx", "build-all.mts"], cwd=self.project_root, check=True + [npx_cmd, "tsx", build_script], + cwd=self.project_root, + check=True, + env=env ) # 4. Parse results @@ -57,25 +74,32 @@ def _ensure_build_script(self): """ Ensure build script exists. - The build script is provided by flick-react NPM package. - Users should have 'flick-react' installed as a devDependency. + Args: + mode: Build mode - determines which script to copy """ - project_build_script = self.project_root / "build-all.mts" + # Use unified build script name + script_name = "build-all.mts" - # Check if flick-react is installed in node_modules - chatjs_build_script = ( - self.project_root / "node_modules" / "fastapps" / "build-all.mts" - ) + project_build_script = self.project_root / script_name + framework_build_script = self.framework_dir / script_name + # Copy from framework if not exists if not project_build_script.exists(): - if chatjs_build_script.exists(): - # Copy from node_modules - shutil.copy(chatjs_build_script, project_build_script) - print("Copied build script from fastapps package") + if framework_build_script.exists(): + shutil.copy(framework_build_script, project_build_script) + print(f"Copied {script_name} from FastApps framework") else: - raise FileNotFoundError( - "build-all.mts not found. Please install fastapps: npm install --save-dev fastapps" + # Fallback: check node_modules + node_modules_script = ( + self.project_root / "node_modules" / "fastapps" / script_name ) + if node_modules_script.exists(): + shutil.copy(node_modules_script, project_build_script) + print(f"Copied {script_name} from fastapps package") + else: + raise FileNotFoundError( + f"{script_name} not found. Please install fastapps: npm install --save-dev fastapps" + ) def _discover_widgets(self): """ diff --git a/fastapps/cli/commands/create.py b/fastapps/cli/commands/create.py index 3da1a23..018e35d 100644 --- a/fastapps/cli/commands/create.py +++ b/fastapps/cli/commands/create.py @@ -1,178 +1,19 @@ """Create widget command.""" from pathlib import Path +import shutil +import json +import subprocess from rich.console import Console console = Console() +# Get templates directory (go up from cli/commands/ to fastapps/) +TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates" -def generate_tool_code( - class_name: str, - identifier: str, - title: str, - auth_type: str = None, - scopes: list = None, -) -> str: - """Generate tool code with optional authentication.""" - # Base imports - imports = "from fastapps import BaseWidget, ConfigDict" - - # Add auth imports if needed - if auth_type == "required": - imports += ", auth_required, UserContext" - elif auth_type == "none": - imports += ", no_auth" - elif auth_type == "optional": - imports += ", optional_auth, UserContext" - else: - # Include commented examples - imports += "\n# from fastapps import auth_required, no_auth, optional_auth, UserContext" - - imports += "\nfrom pydantic import BaseModel\nfrom typing import Dict, Any" - - # Generate decorator - decorator = "" - if auth_type == "required": - scope_str = f"[{', '.join(repr(s) for s in scopes)}]" if scopes else "[]" - decorator = f"@auth_required(scopes={scope_str})" - elif auth_type == "none": - decorator = "@no_auth" - elif auth_type == "optional": - scope_str = f"[{', '.join(repr(s) for s in scopes)}]" if scopes else "[]" - decorator = f"@optional_auth(scopes={scope_str})" - else: - # Commented examples - decorator = """# Optional: Add authentication -# @auth_required(scopes=["user"]) -# @no_auth -# @optional_auth(scopes=["user"])""" - - # Generate execute body based on auth type - if auth_type in ["required", "optional"]: - execute_body = """ # Access authenticated user - if user and user.is_authenticated: - return { - "message": f"Hello, {user.claims.get('name', 'User')}!", - "user_id": user.subject, - "scopes": user.scopes, - } - - return { - "message": "Welcome to FastApps" - }""" - else: - execute_body = """ return { - "message": "Welcome to FastApps" - }""" - - # Generate description based on auth type - if auth_type == "required": - scope_desc = f" ({', '.join(scopes)})" if scopes else "" - description = f"Requires authentication{scope_desc}" - elif auth_type == "none": - description = "Public widget - no authentication required" - elif auth_type == "optional": - description = "Supports both authenticated and anonymous access" - else: - description = "" - - # Format description line - description_line = "" if not description else f'\n description = "{description}"' - - return f"""{imports} - - -class {class_name}Input(BaseModel): - model_config = ConfigDict(populate_by_name=True) - - -{decorator} -class {class_name}Tool(BaseWidget): - identifier = "{identifier}" - title = "{title}"{description_line} - input_schema = {class_name}Input - invoking = "Loading widget..." - invoked = "Widget ready!" - - widget_csp = {{ - "connect_domains": [], - "resource_domains": [] - }} - - async def execute(self, input_data: {class_name}Input, context=None, user=None) -> Dict[str, Any]: -{execute_body} -""" - - -TOOL_TEMPLATE = """from fastapps import BaseWidget, ConfigDict -from pydantic import BaseModel -from typing import Dict, Any - -# Optional: Add per-widget authentication -# from fastapps import auth_required, no_auth, optional_auth - - -class {ClassName}Input(BaseModel): - model_config = ConfigDict(populate_by_name=True) - - -# Optional: Require authentication for this widget -# @auth_required(scopes=["user"]) -# Or make it explicitly public: -# @no_auth -# Or support both authenticated and anonymous: -# @optional_auth(scopes=["user"]) -class {ClassName}Tool(BaseWidget): - identifier = "{identifier}" - title = "{title}" - input_schema = {ClassName}Input - invoking = "Loading widget..." - invoked = "Widget ready!" - - widget_csp = {{ - "connect_domains": [], - "resource_domains": [] - }} - - async def execute(self, input_data: {ClassName}Input, context=None, user=None) -> Dict[str, Any]: - # Access authenticated user (if present) - # if user and user.is_authenticated: - # return {{ - # "message": f"Hello {{user.subject}}!", - # "scopes": user.scopes, - # "user_data": user.claims - # }} - - return {{ - "message": "Welcome to FastApps" - }} -""" - -WIDGET_TEMPLATE = """import React from 'react'; -import {{ useWidgetProps }} from 'fastapps'; - -export default function {ClassName}() {{ - const props = useWidgetProps(); - - return ( -
-

{{props?.message || 'Welcome to FastApps'}}

-
- ); -}} -""" - - -def create_widget(name: str, auth_type: str = None, scopes: list = None): +def create_widget(name: str, auth_type: str = None, scopes: list = None, template: str = None): """ Create a new widget with tool and component files. @@ -180,6 +21,7 @@ def create_widget(name: str, auth_type: str = None, scopes: list = None): name: Widget name auth_type: Authentication type ('required', 'none', 'optional', or None) scopes: List of OAuth scopes + template: Template type ('list', 'carousel', 'albums', or None for default) """ # Convert name to proper formats @@ -209,20 +51,199 @@ def create_widget(name: str, auth_type: str = None, scopes: list = None): tool_dir.mkdir(parents=True, exist_ok=True) widget_dir.mkdir(parents=True, exist_ok=True) - # Generate files with auth configuration - tool_content = generate_tool_code( - class_name=class_name, - identifier=identifier, - title=title, - auth_type=auth_type, - scopes=scopes, - ) + # Determine template to use + template_name = template if template else "default" + template_dir = TEMPLATES_DIR / template_name - widget_content = WIDGET_TEMPLATE.format(ClassName=class_name) + if not template_dir.exists(): + console.print(f"[red]Template directory not found: {template_dir}[/red]") + return False - # Write files - tool_file.write_text(tool_content) - widget_file.write_text(widget_content) + # Copy and customize tool.py + tool_template_file = template_dir / "tool.py" + if tool_template_file.exists(): + tool_content = tool_template_file.read_text() + tool_content = tool_content.replace("{ClassName}", class_name) + tool_content = tool_content.replace("{identifier}", identifier) + tool_content = tool_content.replace("{title}", title) + + # Handle auth configuration for default template + if template_name == "default" and auth_type: + # Add auth imports and decorators + if auth_type == "required": + tool_content = tool_content.replace( + "# from fastapps import auth_required, no_auth, optional_auth, UserContext", + "from fastapps import auth_required, UserContext" + ) + scope_str = f"[{', '.join(repr(s) for s in scopes)}]" if scopes else "[]" + tool_content = tool_content.replace( + "# @auth_required(scopes=[\"user\"])\n# Or make it explicitly public:\n# @no_auth\n# Or support both authenticated and anonymous:\n# @optional_auth(scopes=[\"user\"])", + f"@auth_required(scopes={scope_str})" + ) + elif auth_type == "none": + tool_content = tool_content.replace( + "# from fastapps import auth_required, no_auth, optional_auth, UserContext", + "from fastapps import no_auth" + ) + tool_content = tool_content.replace( + "# @auth_required(scopes=[\"user\"])\n# Or make it explicitly public:\n# @no_auth\n# Or support both authenticated and anonymous:\n# @optional_auth(scopes=[\"user\"])", + "@no_auth" + ) + elif auth_type == "optional": + tool_content = tool_content.replace( + "# from fastapps import auth_required, no_auth, optional_auth, UserContext", + "from fastapps import optional_auth, UserContext" + ) + scope_str = f"[{', '.join(repr(s) for s in scopes)}]" if scopes else "[]" + tool_content = tool_content.replace( + "# @auth_required(scopes=[\"user\"])\n# Or make it explicitly public:\n# @no_auth\n# Or support both authenticated and anonymous:\n# @optional_auth(scopes=[\"user\"])", + f"@optional_auth(scopes={scope_str})" + ) + + tool_file.write_text(tool_content) + + # Copy widget files + widget_template_dir = template_dir / "widget" + if widget_template_dir.exists(): + for item in widget_template_dir.iterdir(): + if item.is_file(): + dest_file = widget_dir / item.name + if item.suffix == ".jsx": + # Customize .jsx files with class name + content = item.read_text() + content = content.replace("{ClassName}", class_name) + dest_file.write_text(content) + else: + # Copy other files as-is (like index.css) + shutil.copy2(item, dest_file) + elif item.is_dir(): + # Copy subdirectories (like hooks folder) recursively + dest_dir = widget_dir / item.name + shutil.copytree(item, dest_dir, dirs_exist_ok=True) + + # Install additional dependencies for templates + if template in ["list", "carousel", "albums"]: + console.print(f"\n[green][OK] Widget '{name}' created from '{template}' template![/green]") + + dep_name = {"list": "Tailwind CSS", "carousel": "Carousel", "albums": "Albums"}.get(template, "Template") + console.print(f"\n[cyan]Installing {dep_name} dependencies...[/cyan]") + + # Check if package.json exists + package_json_path = Path("package.json") + if package_json_path.exists(): + try: + # Read current package.json + with open(package_json_path, 'r') as f: + package_data = json.load(f) + + # Add Tailwind dependencies to devDependencies + if 'devDependencies' not in package_data: + package_data['devDependencies'] = {} + if 'dependencies' not in package_data: + package_data['dependencies'] = {} + + # Template-specific dependencies + if template == "list": + # Dev dependencies for list + template_dev_deps = { + "@tailwindcss/vite": "^4.1.11", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.11" + } + # Runtime dependencies for list + template_deps = { + "lucide-react": "^0.552.0" + } + elif template == "carousel": + # Dev dependencies for carousel + template_dev_deps = { + "@tailwindcss/vite": "^4.1.11", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.11" + } + # Runtime dependencies for carousel + template_deps = { + "lucide-react": "^0.552.0", + "embla-carousel-react": "^8.6.0" + } + elif template == "albums": + # Dev dependencies for albums + template_dev_deps = { + "@tailwindcss/vite": "^4.1.11", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.11" + } + # Runtime dependencies for albums + template_deps = { + "lucide-react": "^0.552.0", + "embla-carousel-react": "^8.6.0" + } + else: + template_dev_deps = {} + template_deps = {} + + # Check if dependencies already exist + deps_to_install = [] + for dep, version in template_dev_deps.items(): + if dep not in package_data['devDependencies']: + package_data['devDependencies'][dep] = version + deps_to_install.append(dep) + + for dep, version in template_deps.items(): + if dep not in package_data['dependencies']: + package_data['dependencies'][dep] = version + deps_to_install.append(dep) + + # Write updated package.json + if deps_to_install: + with open(package_json_path, 'w') as f: + json.dump(package_data, f, indent=2) + + dep_type = "template" if template else "Tailwind" + console.print(f"[cyan]Added {len(deps_to_install)} {dep_type} dependencies to package.json[/cyan]") + + # Run npm install + console.print("[cyan]Running npm install...[/cyan]") + try: + result = subprocess.run( + ["npm", "install"], + capture_output=True, + text=True, + check=True + ) + success_msg = f"{dep_name} dependencies" if template in ["list", "carousel", "albums"] else "Dependencies" + console.print(f"[green]✓ {success_msg} installed[/green]") + except subprocess.CalledProcessError as e: + console.print("[yellow]⚠ npm install failed. Run 'npm install' manually[/yellow]") + except FileNotFoundError: + console.print("[yellow]⚠ npm not found. Run 'npm install' manually[/yellow]") + else: + success_msg = f"{dep_name} dependencies" if template in ["list", "carousel", "albums"] else "Dependencies" + console.print(f"[green]✓ {success_msg} already installed[/green]") + + except Exception as e: + console.print(f"[yellow]⚠ Could not update package.json: {e}[/yellow]") + if template in ["carousel", "albums"]: + console.print(f"[yellow]Please install {template} dependencies manually:[/yellow]") + console.print("[dim] npm install embla-carousel-react lucide-react[/dim]") + console.print("[dim] npm install -D @tailwindcss/vite tailwindcss autoprefixer postcss[/dim]") + else: + console.print("[yellow]Please install Tailwind CSS manually:[/yellow]") + console.print("[dim] npm install -D @tailwindcss/vite tailwindcss autoprefixer postcss[/dim]") + else: + console.print("[yellow]⚠ package.json not found[/yellow]") + if template in ["carousel", "albums"]: + console.print(f"[yellow]Please install {template} dependencies manually:[/yellow]") + console.print("[dim] npm install embla-carousel-react lucide-react[/dim]") + console.print("[dim] npm install -D @tailwindcss/vite tailwindcss autoprefixer postcss[/dim]") + else: + console.print("[yellow]Please install Tailwind CSS manually:[/yellow]") + console.print("[dim] npm install -D @tailwindcss/vite tailwindcss autoprefixer postcss[/dim]") + else: + console.print(f"\n[green][OK] Widget '{name}' created![/green]") console.print("\n[green][OK] Widget created successfully![/green]") console.print("\n[cyan]Created files:[/cyan]") diff --git a/fastapps/cli/commands/dev.py b/fastapps/cli/commands/dev.py index 22dd87e..948a8c8 100644 --- a/fastapps/cli/commands/dev.py +++ b/fastapps/cli/commands/dev.py @@ -1,10 +1,14 @@ """Development server command with Cloudflare Tunnel integration.""" +import http.server import json +import os import platform import re +import socketserver import subprocess import sys +import threading import time from pathlib import Path @@ -114,8 +118,40 @@ def start_cloudflare_tunnel(port: int) -> tuple[subprocess.Popen, str]: return process, public_url -def start_dev_server(port=8001, host="0.0.0.0"): - """Start development server with Cloudflare Tunnel.""" +def start_asset_server(assets_dir: Path, port: int = 4444): + """ + Start static file server for assets (hosted mode). + + Serves files from the assets directory with CORS headers enabled. + """ + class CORSHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(assets_dir), **kwargs) + + def end_headers(self): + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS') + self.send_header('Access-Control-Allow-Headers', 'Content-Type') + super().end_headers() + + def log_message(self, format, *args): + # Suppress request logs to keep output clean + pass + + handler = CORSHTTPRequestHandler + with socketserver.TCPServer(("", port), handler) as httpd: + console.print(f"[green]✓ Asset server running on http://localhost:{port}[/green]") + httpd.serve_forever() + + +def start_dev_server(port=8001, host="0.0.0.0", mode="hosted"): + """Start development server with Cloudflare Tunnel. + + Args: + port: Port for MCP server (default: 8001) + host: Host to bind server (default: "0.0.0.0") + mode: Build mode - "hosted" (default) or "inline" + """ # Check if we're in a FastApps project if not Path("server/main.py").exists(): @@ -125,6 +161,24 @@ def start_dev_server(port=8001, host="0.0.0.0"): ) return False + # Start asset server if hosted mode + asset_server_thread = None + if mode == "hosted": + assets_dir = Path.cwd() / "assets" + if not assets_dir.exists(): + console.print("[yellow]Creating assets directory...[/yellow]") + assets_dir.mkdir(parents=True, exist_ok=True) + + console.print(f"[cyan]Starting asset server on port 4444...[/cyan]") + asset_server_thread = threading.Thread( + target=start_asset_server, + args=(assets_dir, 4444), + daemon=True + ) + asset_server_thread.start() + time.sleep(0.5) + console.print() + # Check if cloudflared is installed if not check_cloudflared_installed(): console.print("[yellow]cloudflared not found[/yellow]") @@ -150,7 +204,8 @@ def start_dev_server(port=8001, host="0.0.0.0"): # Import project server sys.path.insert(0, str(Path.cwd())) # Reset sys.argv to avoid argparse conflicts in server/main.py - sys.argv = ["server/main.py", "--build"] # Enable build mode for development + # Pass mode to server for builder + sys.argv = ["server/main.py", "--build", f"--mode={mode}"] from server.main import app # Create server config @@ -158,9 +213,6 @@ def start_dev_server(port=8001, host="0.0.0.0"): server = uvicorn.Server(config) # Start server in background thread to show info panel - import threading - import time - def run_server(): asyncio.run(server.serve()) diff --git a/fastapps/cli/commands/init.py b/fastapps/cli/commands/init.py index e0012da..3263258 100644 --- a/fastapps/cli/commands/init.py +++ b/fastapps/cli/commands/init.py @@ -71,14 +71,20 @@ def auto_load_tools(build_results): action="store_true", help="Build widgets on startup (for development)" ) +parser.add_argument( + "--mode", + choices=["inline", "hosted"], + default="hosted", + help="Widget build mode: hosted (default) or inline" +) args = parser.parse_args() # Load build results if args.build: # Build widgets on startup - print(f"[INFO] Building widgets") + print(f"[INFO] Building widgets (mode: {args.mode})") builder = WidgetBuilder(PROJECT_ROOT) - build_results = builder.build_all() + build_results = builder.build_all(mode=args.mode) else: # Load pre-built widgets from assets directory print(f"[INFO] Loading pre-built widgets from assets") diff --git a/fastapps/cli/main.py b/fastapps/cli/main.py index 9c70809..c14deea 100644 --- a/fastapps/cli/main.py +++ b/fastapps/cli/main.py @@ -50,7 +50,10 @@ def init(project_name): @click.option("--public", is_flag=True, help="Add no_auth decorator (public widget)") @click.option("--optional-auth", is_flag=True, help="Add optional_auth decorator") @click.option("--scopes", help="OAuth scopes (comma-separated, e.g., 'user,read:data')") -def create(widget_name, auth, public, optional_auth, scopes): +@click.option("--list", "use_list_template", is_flag=True, help="Create a list widget using the list template") +@click.option("--carousel", "use_carousel_template", is_flag=True, help="Create a carousel widget using the carousel template") +@click.option("--albums", "use_albums_template", is_flag=True, help="Create an albums widget using the albums template") +def create(widget_name, auth, public, optional_auth, scopes, use_list_template, use_carousel_template, use_albums_template): """Create a new widget with tool and component files. Examples: @@ -58,12 +61,20 @@ def create(widget_name, auth, public, optional_auth, scopes): fastapps create mywidget --auth --scopes user,read:data fastapps create mywidget --public fastapps create mywidget --optional-auth --scopes user + fastapps create mywidget --list + fastapps create mywidget --carousel + fastapps create mywidget --albums Authentication options: --auth: Require OAuth authentication --public: Mark as public (no auth) --optional-auth: Support both authenticated and anonymous --scopes: OAuth scopes to require + + Template options: + --list: Use the list widget template (includes Tailwind CSS styling) + --carousel: Use the carousel widget template (includes Embla Carousel) + --albums: Use the albums widget template (includes fullscreen photo viewer) """ # Parse scopes scope_list = scopes.split(",") if scopes else None @@ -85,7 +96,24 @@ def create(widget_name, auth, public, optional_auth, scopes): elif optional_auth: auth_type = "optional" - create_widget(widget_name, auth_type=auth_type, scopes=scope_list) + # Validate template options + template_count = sum([use_list_template, use_carousel_template, use_albums_template]) + if template_count > 1: + console.print( + "[red]Error: Only one template option allowed (--list, --carousel, or --albums)[/red]" + ) + return + + # Determine template + template = None + if use_list_template: + template = "list" + elif use_carousel_template: + template = "carousel" + elif use_albums_template: + template = "albums" + + create_widget(widget_name, auth_type=auth_type, scopes=scope_list, template=template) @cli.command() @@ -93,23 +121,34 @@ def create(widget_name, auth, public, optional_auth, scopes): @click.option( "--host", default="0.0.0.0", help="Host to bind the server to (default: 0.0.0.0)" ) -def dev(port, host): +@click.option( + "--mode", + type=click.Choice(['inline', 'hosted'], case_sensitive=False), + default='hosted', + help="Widget build mode: 'hosted' (default, external JS/CSS on port 4444) or 'inline' (self-contained HTML)" +) +def dev(port, host, mode): """Start development server with Cloudflare Tunnel. This command will: - 1. Build widgets + 1. Build widgets (inline or hosted mode) 2. Install cloudflared if needed (automatic, no token required) 3. Start a public Cloudflare Tunnel 4. Launch the FastApps development server 5. Display public and local URLs - Example: - fastapps dev - fastapps dev --port 8080 + Build modes: + --mode=hosted : Widgets reference external JS/CSS from localhost:4444 (default, faster dev, ChatGPT compatible) + --mode=inline : Widgets built as self-contained HTML (production-ready) + + Examples: + fastapps dev # Hosted mode (default) + fastapps dev --mode=inline # Inline mode (self-contained) + fastapps dev --port 8080 # Custom port Note: Uses Cloudflare Tunnel (free, unlimited, no sign-up required) """ - start_dev_server(port=port, host=host) + start_dev_server(port=port, host=host, mode=mode) @cli.command() diff --git a/fastapps/templates/albums/tool.py b/fastapps/templates/albums/tool.py new file mode 100644 index 0000000..31be6a9 --- /dev/null +++ b/fastapps/templates/albums/tool.py @@ -0,0 +1,89 @@ +from fastapps import BaseWidget, ConfigDict +from pydantic import BaseModel, Field + + +class {ClassName}Input(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + # Example parameter - customize based on your widget's needs + query: str = Field( + default="", + description="Search query or filter parameter" + ) + + +class {ClassName}Tool(BaseWidget): + identifier = "{identifier}" + title = "{title}" + description = "Photo albums gallery widget with fullscreen viewer" + invoking = "Loading albums..." + invoked = "Albums ready!" + input_schema = {ClassName}Input + + async def execute(self, input_data: {ClassName}Input, context=None, user=None): + # Example: Return sample albums + return { + "albums": [ + { + "id": "album-1", + "title": "Sample Album 1", + "cover": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-1.png", + "photos": [ + { + "id": "p1", + "title": "Photo 1", + "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-1.png" + }, + { + "id": "p2", + "title": "Photo 2", + "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-2.png" + }, + { + "id": "p3", + "title": "Photo 3", + "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-3.png" + } + ] + }, + { + "id": "album-2", + "title": "Sample Album 2", + "cover": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-5.png", + "photos": [ + { + "id": "p4", + "title": "Photo 4", + "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-4.png" + }, + { + "id": "p5", + "title": "Photo 5", + "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-5.png" + } + ] + }, + { + "id": "album-3", + "title": "Sample Album 3", + "cover": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-7.png", + "photos": [ + { + "id": "p6", + "title": "Photo 6", + "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-6.png" + }, + { + "id": "p7", + "title": "Photo 7", + "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-7.png" + }, + { + "id": "p8", + "title": "Photo 8", + "url": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-8.png" + } + ] + } + ] + } diff --git a/fastapps/templates/albums/widget/AlbumCard.jsx b/fastapps/templates/albums/widget/AlbumCard.jsx new file mode 100644 index 0000000..9867194 --- /dev/null +++ b/fastapps/templates/albums/widget/AlbumCard.jsx @@ -0,0 +1,28 @@ +import React from "react"; + +function AlbumCard({ album, onSelect }) { + return ( + + ); +} + +export default AlbumCard; diff --git a/fastapps/templates/albums/widget/FilmStrip.jsx b/fastapps/templates/albums/widget/FilmStrip.jsx new file mode 100644 index 0000000..049cd95 --- /dev/null +++ b/fastapps/templates/albums/widget/FilmStrip.jsx @@ -0,0 +1,30 @@ +import React from "react"; + +export default function FilmStrip({ album, selectedIndex, onSelect }) { + return ( +
+ {album.photos.map((photo, idx) => ( + + ))} +
+ ); +} diff --git a/fastapps/templates/albums/widget/FullscreenViewer.jsx b/fastapps/templates/albums/widget/FullscreenViewer.jsx new file mode 100644 index 0000000..6ae2802 --- /dev/null +++ b/fastapps/templates/albums/widget/FullscreenViewer.jsx @@ -0,0 +1,60 @@ +import React from "react"; +import { ArrowLeft } from "lucide-react"; +import { useMaxHeight } from "./hooks/use-max-height"; +import FilmStrip from "./FilmStrip"; + +export default function FullscreenViewer({ album, onBack }) { + const maxHeight = useMaxHeight() ?? undefined; + const [index, setIndex] = React.useState(0); + + React.useEffect(() => { + setIndex(0); + }, [album?.id]); + + const photo = album?.photos?.[index]; + + return ( +
+ {/* Back button */} + {onBack && ( + + )} + +
+ {/* Film strip */} +
+ +
+ {/* Main photo */} +
+
+ {photo ? ( + {photo.title + ) : null} +
+
+
+
+ ); +} diff --git a/fastapps/templates/albums/widget/hooks/types.ts b/fastapps/templates/albums/widget/hooks/types.ts new file mode 100644 index 0000000..765164e --- /dev/null +++ b/fastapps/templates/albums/widget/hooks/types.ts @@ -0,0 +1,101 @@ +export type OpenAiGlobals< + ToolInput = UnknownObject, + ToolOutput = UnknownObject, + ToolResponseMetadata = UnknownObject, + WidgetState = UnknownObject +> = { + // visuals + theme: Theme; + + userAgent: UserAgent; + locale: string; + + // layout + maxHeight: number; + displayMode: DisplayMode; + safeArea: SafeArea; + + // state + toolInput: ToolInput; + toolOutput: ToolOutput | null; + toolResponseMetadata: ToolResponseMetadata | null; + widgetState: WidgetState | null; + setWidgetState: (state: WidgetState) => Promise; +}; + +// currently copied from types.ts in chatgpt/web-sandbox. +// Will eventually use a public package. +type API = { + callTool: CallTool; + sendFollowUpMessage: (args: { prompt: string }) => Promise; + openExternal(payload: { href: string }): void; + + // Layout controls + requestDisplayMode: RequestDisplayMode; +}; + +export type UnknownObject = Record; + +export type Theme = "light" | "dark"; + +export type SafeAreaInsets = { + top: number; + bottom: number; + left: number; + right: number; +}; + +export type SafeArea = { + insets: SafeAreaInsets; +}; + +export type DeviceType = "mobile" | "tablet" | "desktop" | "unknown"; + +export type UserAgent = { + device: { type: DeviceType }; + capabilities: { + hover: boolean; + touch: boolean; + }; +}; + +/** Display mode */ +export type DisplayMode = "pip" | "inline" | "fullscreen"; +export type RequestDisplayMode = (args: { mode: DisplayMode }) => Promise<{ + /** + * The granted display mode. The host may reject the request. + * For mobile, PiP is always coerced to fullscreen. + */ + mode: DisplayMode; +}>; + +export type CallToolResponse = { + result: string; +}; + +/** Calling APIs */ +export type CallTool = ( + name: string, + args: Record +) => Promise; + +/** Extra events */ +export const SET_GLOBALS_EVENT_TYPE = "openai:set_globals"; +export class SetGlobalsEvent extends CustomEvent<{ + globals: Partial; +}> { + readonly type = SET_GLOBALS_EVENT_TYPE; +} + +/** + * Global oai object injected by the web sandbox for communicating with chatgpt host page. + */ +declare global { + interface Window { + openai: API & OpenAiGlobals; + } + + interface WindowEventMap { + [SET_GLOBALS_EVENT_TYPE]: SetGlobalsEvent; + } +} diff --git a/fastapps/templates/albums/widget/hooks/use-max-height.ts b/fastapps/templates/albums/widget/hooks/use-max-height.ts new file mode 100644 index 0000000..5e0d8c3 --- /dev/null +++ b/fastapps/templates/albums/widget/hooks/use-max-height.ts @@ -0,0 +1,5 @@ +import { useOpenAiGlobal } from "./use-openai-global"; + +export const useMaxHeight = (): number | null => { + return useOpenAiGlobal("maxHeight"); +}; diff --git a/fastapps/templates/albums/widget/hooks/use-openai-global.ts b/fastapps/templates/albums/widget/hooks/use-openai-global.ts new file mode 100644 index 0000000..f8a20a1 --- /dev/null +++ b/fastapps/templates/albums/widget/hooks/use-openai-global.ts @@ -0,0 +1,37 @@ +import { useSyncExternalStore } from "react"; +import { + SET_GLOBALS_EVENT_TYPE, + SetGlobalsEvent, + type OpenAiGlobals, +} from "./types"; + +export function useOpenAiGlobal( + key: K +): OpenAiGlobals[K] | null { + return useSyncExternalStore( + (onChange) => { + if (typeof window === "undefined") { + return () => {}; + } + + const handleSetGlobal = (event: SetGlobalsEvent) => { + const value = event.detail.globals[key]; + if (value === undefined) { + return; + } + + onChange(); + }; + + window.addEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal, { + passive: true, + }); + + return () => { + window.removeEventListener(SET_GLOBALS_EVENT_TYPE, handleSetGlobal); + }; + }, + () => window.openai?.[key] ?? null, + () => window.openai?.[key] ?? null + ); +} diff --git a/fastapps/templates/albums/widget/index.css b/fastapps/templates/albums/widget/index.css new file mode 100644 index 0000000..7b9c76c --- /dev/null +++ b/fastapps/templates/albums/widget/index.css @@ -0,0 +1,40 @@ +@import "tailwindcss" source("."); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer utilities { + .overflow-auto > *, + .overflow-scroll > *, + .overflow-x-auto > *, + .overflow-y-auto > * { + scrollbar-color: auto; + } + + /* Base style for scrollable elements */ + .overflow-auto, + .overflow-scroll, + .overflow-x-auto, + .overflow-y-auto, + .overflow-x-scroll, + .overflow-y-scroll { + scrollbar-color: rgb(0, 0, 0, 0.1) transparent; + + @media (prefers-color-scheme: dark) { + scrollbar-color: rgb(255, 255, 255, 0.1) transparent; + } + } + + /* Hover state directly on the scrollable element */ + .overflow-auto:hover, + .overflow-scroll:hover, + .overflow-x-auto:hover, + .overflow-y-auto:hover { + scrollbar-color: rgb(0, 0, 0, 0.2) transparent; + + @media (prefers-color-scheme: dark) { + scrollbar-color: rgb(255, 255, 255, 0.2) transparent; + } + } +} diff --git a/fastapps/templates/albums/widget/index.jsx b/fastapps/templates/albums/widget/index.jsx new file mode 100644 index 0000000..0f1ac5e --- /dev/null +++ b/fastapps/templates/albums/widget/index.jsx @@ -0,0 +1,164 @@ +import React, { useEffect } from "react"; +import { useWidgetProps } from "fastapps"; +import useEmblaCarousel from "embla-carousel-react"; +import { ArrowLeft, ArrowRight } from "lucide-react"; +import { useMaxHeight } from "./hooks/use-max-height"; +import { useOpenAiGlobal } from "./hooks/use-openai-global"; +import FullscreenViewer from "./FullscreenViewer"; +import AlbumCard from "./AlbumCard"; +import "./index.css"; + +function AlbumsCarousel({ onSelect }) { + const widgetProps = useWidgetProps(); + const albums = widgetProps?.albums || []; + const [emblaRef, emblaApi] = useEmblaCarousel({ + align: "center", + loop: false, + containScroll: "trimSnaps", + slidesToScroll: "auto", + dragFree: false, + }); + const [canPrev, setCanPrev] = React.useState(false); + const [canNext, setCanNext] = React.useState(false); + + React.useEffect(() => { + if (!emblaApi) return; + const updateButtons = () => { + setCanPrev(emblaApi.canScrollPrev()); + setCanNext(emblaApi.canScrollNext()); + }; + updateButtons(); + emblaApi.on("select", updateButtons); + emblaApi.on("reInit", updateButtons); + return () => { + emblaApi.off("select", updateButtons); + emblaApi.off("reInit", updateButtons); + }; + }, [emblaApi]); + + return ( +
+
+
+ {albums.map((album, i) => ( +
+ +
+ ))} +
+
+
+
+
+
+
+
+ {canPrev && ( + + )} + {canNext && ( + + )} +
+ ); +} + +function {ClassName}() { + const widgetProps = useWidgetProps(); + const displayMode = useOpenAiGlobal("displayMode"); + const [selectedAlbum, setSelectedAlbum] = React.useState(null); + const maxHeight = useMaxHeight() ?? undefined; + + useEffect(() => { + if (widgetProps) { + console.log('Albums widget props:', widgetProps); + } + }, [widgetProps]); + + const handleSelectAlbum = (album) => { + setSelectedAlbum(album); + if (window?.openai?.requestDisplayMode) { + window.openai.requestDisplayMode({ mode: "fullscreen" }); + } + }; + + const handleBackToAlbums = () => { + setSelectedAlbum(null); + if (window?.openai?.requestDisplayMode) { + window.openai.requestDisplayMode({ mode: "inline" }); + } + }; + + return ( +
+ {displayMode !== "fullscreen" && ( + + )} + + {displayMode === "fullscreen" && selectedAlbum && ( + + )} +
+ ); +} + +export default {ClassName}; diff --git a/fastapps/templates/carousel/tool.py b/fastapps/templates/carousel/tool.py new file mode 100644 index 0000000..71030d4 --- /dev/null +++ b/fastapps/templates/carousel/tool.py @@ -0,0 +1,68 @@ +from fastapps import BaseWidget, ConfigDict +from pydantic import BaseModel, Field + + +class {ClassName}Input(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + # Example parameter - customize based on your widget's needs + query: str = Field( + default="", + description="Search query or filter parameter" + ) + + +class {ClassName}Tool(BaseWidget): + identifier = "{identifier}" + title = "{title}" + description = "A horizontal scrolling carousel widget" + invoking = "Loading carousel..." + invoked = "Carousel ready!" + input_schema = {ClassName}Input + + async def execute(self, input_data: {ClassName}Input, context=None, user=None): + # Example: Return sample cards for carousel + return { + "cards": [ + { + "id": 1, + "name": "Sample Card 1", + "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-1.png", + "rating": 4.5, + "price": "$$", + "description": "Description for Card 1" + }, + { + "id": 2, + "name": "Sample Card 2", + "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-5.png", + "rating": 4.2, + "price": "$$$", + "description": "Description for Card 2" + }, + { + "id": 3, + "name": "Sample Card 3", + "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-7.png", + "rating": 4.7, + "price": "$$", + "description": "Description for Card 3" + }, + { + "id": 4, + "name": "Sample Card 4", + "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-9.png", + "rating": 3.8, + "price": "$", + "description": "Description for Card 4" + }, + { + "id": 5, + "name": "Sample Card 5", + "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-10.png", + "rating": 4.9, + "price": "$$$$", + "description": "Description for Card 5" + }, + ] + } diff --git a/fastapps/templates/carousel/widget/Card.jsx b/fastapps/templates/carousel/widget/Card.jsx new file mode 100644 index 0000000..3f2d01b --- /dev/null +++ b/fastapps/templates/carousel/widget/Card.jsx @@ -0,0 +1,38 @@ +import React from "react"; +import { Star } from "lucide-react"; + +export default function Card({ card }) { + if (!card) return null; + return ( +
+
+ {card.name} +
+
+
{card.name}
+
+
+ {card.description ? ( +
+ {card.description} +
+ ) : null} +
+ +
+
+
+ ); +} diff --git a/fastapps/templates/carousel/widget/index.css b/fastapps/templates/carousel/widget/index.css new file mode 100644 index 0000000..7b9c76c --- /dev/null +++ b/fastapps/templates/carousel/widget/index.css @@ -0,0 +1,40 @@ +@import "tailwindcss" source("."); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer utilities { + .overflow-auto > *, + .overflow-scroll > *, + .overflow-x-auto > *, + .overflow-y-auto > * { + scrollbar-color: auto; + } + + /* Base style for scrollable elements */ + .overflow-auto, + .overflow-scroll, + .overflow-x-auto, + .overflow-y-auto, + .overflow-x-scroll, + .overflow-y-scroll { + scrollbar-color: rgb(0, 0, 0, 0.1) transparent; + + @media (prefers-color-scheme: dark) { + scrollbar-color: rgb(255, 255, 255, 0.1) transparent; + } + } + + /* Hover state directly on the scrollable element */ + .overflow-auto:hover, + .overflow-scroll:hover, + .overflow-x-auto:hover, + .overflow-y-auto:hover { + scrollbar-color: rgb(0, 0, 0, 0.2) transparent; + + @media (prefers-color-scheme: dark) { + scrollbar-color: rgb(255, 255, 255, 0.2) transparent; + } + } +} diff --git a/fastapps/templates/carousel/widget/index.jsx b/fastapps/templates/carousel/widget/index.jsx new file mode 100644 index 0000000..09c5ff8 --- /dev/null +++ b/fastapps/templates/carousel/widget/index.jsx @@ -0,0 +1,125 @@ +import React, { useEffect } from "react"; +import { useWidgetProps } from "fastapps"; +import useEmblaCarousel from "embla-carousel-react"; +import { ArrowLeft, ArrowRight } from "lucide-react"; +import Card from "./Card"; +import "./index.css"; + +function {ClassName}() { + const widgetProps = useWidgetProps(); + const cards = widgetProps?.cards || []; + + useEffect(() => { + if (widgetProps) { + console.log('Carousel widget props:', widgetProps); + } + }, [widgetProps]); + + const [emblaRef, emblaApi] = useEmblaCarousel({ + align: "center", + loop: false, + containScroll: "trimSnaps", + slidesToScroll: "auto", + dragFree: false, + }); + + const [canPrev, setCanPrev] = React.useState(false); + const [canNext, setCanNext] = React.useState(false); + + React.useEffect(() => { + if (!emblaApi) return; + const updateButtons = () => { + setCanPrev(emblaApi.canScrollPrev()); + setCanNext(emblaApi.canScrollNext()); + }; + updateButtons(); + emblaApi.on("select", updateButtons); + emblaApi.on("reInit", updateButtons); + return () => { + emblaApi.off("select", updateButtons); + emblaApi.off("reInit", updateButtons); + }; + }, [emblaApi]); + + return ( +
+
+
+ {cards.map((card, i) => ( +
+ +
+ ))} +
+
+ {/* Edge gradients */} +
+
+
+
+
+
+ {canPrev && ( + + )} + {canNext && ( + + )} +
+ ); +} + +export default {ClassName}; diff --git a/fastapps/templates/default/tool.py b/fastapps/templates/default/tool.py new file mode 100644 index 0000000..8ce3731 --- /dev/null +++ b/fastapps/templates/default/tool.py @@ -0,0 +1,42 @@ +from fastapps import BaseWidget, ConfigDict +from pydantic import BaseModel +from typing import Dict, Any + +# Optional: Add per-widget authentication +# from fastapps import auth_required, no_auth, optional_auth, UserContext + + +class {ClassName}Input(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + +# Optional: Require authentication for this widget +# @auth_required(scopes=["user"]) +# Or make it explicitly public: +# @no_auth +# Or support both authenticated and anonymous: +# @optional_auth(scopes=["user"]) +class {ClassName}Tool(BaseWidget): + identifier = "{identifier}" + title = "{title}" + input_schema = {ClassName}Input + invoking = "Loading widget..." + invoked = "Widget ready!" + + widget_csp = { + "connect_domains": [], + "resource_domains": [] + } + + async def execute(self, input_data: {ClassName}Input, context=None, user=None) -> Dict[str, Any]: + # Access authenticated user (if present) + # if user and user.is_authenticated: + # return { + # "message": f"Hello {user.subject}!", + # "scopes": user.scopes, + # "user_data": user.claims + # } + + return { + "message": "Welcome to FastApps" + } diff --git a/fastapps/templates/default/widget/index.jsx b/fastapps/templates/default/widget/index.jsx new file mode 100644 index 0000000..8de1186 --- /dev/null +++ b/fastapps/templates/default/widget/index.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { useWidgetProps } from 'fastapps'; + +export default function {ClassName}() { + const props = useWidgetProps(); + + return ( +
+

{props?.message || 'Welcome to FastApps'}

+
+ ); +} diff --git a/fastapps/templates/list/tool.py b/fastapps/templates/list/tool.py new file mode 100644 index 0000000..4e29548 --- /dev/null +++ b/fastapps/templates/list/tool.py @@ -0,0 +1,65 @@ +from fastapps import BaseWidget, ConfigDict +from pydantic import BaseModel, Field + + +class {ClassName}Input(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + # Example parameter - customize based on your widget's needs + query: str = Field( + default="", + description="Search query or filter parameter" + ) + + +class {ClassName}Tool(BaseWidget): + identifier = "{identifier}" + title = "{title}" + description = "A ranked list widget" + invoking = "Loading list..." + invoked = "List ready!" + input_schema = {ClassName}Input + + async def execute(self, input_data: {ClassName}Input, context=None, user=None): + # Example: Return sample items + return { + "title": "Sample List", + "description": "A list of items", + "items": [ + { + "id": 1, + "name": "Sample Item 1", + "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-1.png", + "rating": 4.5, + "info": "Additional info 1" + }, + { + "id": 2, + "name": "Sample Item 2", + "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-5.png", + "rating": 4.2, + "info": "Additional info 2" + }, + { + "id": 3, + "name": "Sample Item 3", + "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-7.png", + "rating": 4.7, + "info": "Additional info 3" + }, + { + "id": 4, + "name": "Sample Item 4", + "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-9.png", + "rating": 3.8, + "info": "Additional info 4" + }, + { + "id": 5, + "name": "Sample Item 5", + "thumbnail": "https://pub-d9760dbd87764044a85486be2fdf7f9f.r2.dev/example-10.png", + "rating": 3.9, + "info": "Additional info 5" + }, + ] + } diff --git a/fastapps/templates/list/widget/index.css b/fastapps/templates/list/widget/index.css new file mode 100644 index 0000000..7b9c76c --- /dev/null +++ b/fastapps/templates/list/widget/index.css @@ -0,0 +1,40 @@ +@import "tailwindcss" source("."); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer utilities { + .overflow-auto > *, + .overflow-scroll > *, + .overflow-x-auto > *, + .overflow-y-auto > * { + scrollbar-color: auto; + } + + /* Base style for scrollable elements */ + .overflow-auto, + .overflow-scroll, + .overflow-x-auto, + .overflow-y-auto, + .overflow-x-scroll, + .overflow-y-scroll { + scrollbar-color: rgb(0, 0, 0, 0.1) transparent; + + @media (prefers-color-scheme: dark) { + scrollbar-color: rgb(255, 255, 255, 0.1) transparent; + } + } + + /* Hover state directly on the scrollable element */ + .overflow-auto:hover, + .overflow-scroll:hover, + .overflow-x-auto:hover, + .overflow-y-auto:hover { + scrollbar-color: rgb(0, 0, 0, 0.2) transparent; + + @media (prefers-color-scheme: dark) { + scrollbar-color: rgb(255, 255, 255, 0.2) transparent; + } + } +} diff --git a/fastapps/templates/list/widget/index.jsx b/fastapps/templates/list/widget/index.jsx new file mode 100644 index 0000000..1a077ed --- /dev/null +++ b/fastapps/templates/list/widget/index.jsx @@ -0,0 +1,118 @@ +import React, { useEffect } from "react"; +import { useWidgetProps } from "fastapps"; +import { PlusCircle, Star } from "lucide-react"; +import "./index.css"; + +function {ClassName}() { + const widgetProps = useWidgetProps(); + const items = widgetProps?.items || []; + + useEffect(() => { + if (widgetProps) { + console.log('List widget props:', widgetProps); + } + }, [widgetProps]); + + return ( +
+
+
+
+
+
+ {widgetProps?.title || "List Title"} +
+
+ {widgetProps?.description || "A list of items"} +
+
+
+ +
+
+
+ {items.slice(0, 7).map((item, i) => ( +
+
+
+
+ {item.name} +
+ {i + 1} +
+
+
+ {item.name} +
+
+
+ + + {item.rating?.toFixed + ? item.rating.toFixed(1) + : item.rating} + +
+
+ {item.info || "–"} +
+
+
+
+
+
+ {item.info || "–"} +
+
+ +
+
+
+ ))} + {items.length === 0 && ( +
+ No items found. +
+ )} +
+
+ +
+
+
+ ); +} + +export default {ClassName}; diff --git a/pyproject.toml b/pyproject.toml index 90ce08b..086ce06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fastapps" -version = "1.3.2" +version = "1.3.3" description = "A zero-boilerplate framework for building interactive ChatGPT widgets" readme = "README.md" requires-python = ">=3.11"