diff --git a/examples/cute-dogs-server/README.md b/examples/cute-dogs-server/README.md new file mode 100644 index 00000000..b2bcda2b --- /dev/null +++ b/examples/cute-dogs-server/README.md @@ -0,0 +1,109 @@ +# Cute Dogs MCP Server (MCP Apps UI) + +An MCP (Model Context Protocol) server + MCP Apps UI that provides interactive widgets and tools for browsing and viewing dog images from the [Dog CEO API](https://dog.ceo/dog-api/). + +This example server demonstrates the full MCP Apps capabilities with a React example: + +- Render React UI widgets. The UI is hydrated by data passed in from the MCP tool via `structuredContent` +- Call tool within widget +- Send follow up message +- Open external link + +![Example of the Cute Dogs MCP Server in action](demo-images/example.png) + +## Installation + +Install all dependencies: + +```bash +npm i +``` + +Then start the server + +```bash +npm run start +``` + +The terminal will then print out text `MCP Server listening on http://localhost:3001/mcp`. Connect to the MCP server with the localhost link. + +## Tools + +The server provides three MCP tools: + +### 1. `show-random-dog-image` + +Shows a dog image in an interactive UI widget. The image is displayed in the widget, not in the text response. + +**Parameters:** + +- `breed` (optional): Dog breed name (e.g., `"hound"`, `"retriever"`). If not provided, returns a random dog from any breed. + +**Widget:** `dog-image-view` + +**Example:** + +```json +{ + "name": "show-random-dog-image", + "arguments": { + "breed": "hound" + } +} +``` + +### 2. `all-breeds-view` + +Shows all available dog breeds in an interactive UI widget. Users can click on any breed to send a message to the chat requesting that breed. + +**Parameters:** None + +**Widget:** `all-breeds-view` + +**Example:** + +```json +{ + "name": "all-breeds-view", + "arguments": {} +} +``` + +### 3. `get-more-images` + +Fetches multiple random dog images from a specific breed. Returns an array of image URLs. This tool is typically called from within the `dog-image-view` widget to load additional images. + +**Parameters:** + +- `breed` (required): The dog breed name (e.g., `"hound"`, `"retriever"`) +- `count` (optional): Number of images to fetch (1-30). Defaults to 3 if not provided. + +**Widget:** None (programmatic tool) + +**Example:** + +```json +{ + "name": "get-more-images", + "arguments": { + "breed": "hound", + "count": 5 + } +} +``` + +## How this server is compiled + +1. **Source Files** → React components written in TypeScript/TSX: + - `src/dog-image-view.tsx` - The React component for displaying dog images + - `src/all-breeds-view.tsx` - The React component for showing all breeds + +2. **HTML Entry Points** → Simple HTML files that load the React components: + - `dog-image-view.html` - Loads `dog-image-view.tsx` via a script tag + - `all-breeds-view.html` - Loads `all-breeds-view.tsx` via a script tag + +3. **Vite Build Process** → When you run `npm run build`: + - Vite compiles each HTML file separately (using the `INPUT` environment variable) + - It bundles all React code, CSS, and dependencies into a single HTML file + - Outputs go to `dist/dog-image-view.html` and `dist/all-breeds-view.html` + - These are self-contained, ready-to-serve HTML files diff --git a/examples/cute-dogs-server/all-breeds-view.html b/examples/cute-dogs-server/all-breeds-view.html new file mode 100644 index 00000000..d699c739 --- /dev/null +++ b/examples/cute-dogs-server/all-breeds-view.html @@ -0,0 +1,12 @@ + + + + + + Show all breeds + + +
+ + + diff --git a/examples/cute-dogs-server/components.json b/examples/cute-dogs-server/components.json new file mode 100644 index 00000000..f4ca71e1 --- /dev/null +++ b/examples/cute-dogs-server/components.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/styles.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui" + } +} diff --git a/examples/cute-dogs-server/demo-images/example.png b/examples/cute-dogs-server/demo-images/example.png new file mode 100644 index 00000000..d4ef63ed Binary files /dev/null and b/examples/cute-dogs-server/demo-images/example.png differ diff --git a/examples/cute-dogs-server/dog-image-view.html b/examples/cute-dogs-server/dog-image-view.html new file mode 100644 index 00000000..0c89593a --- /dev/null +++ b/examples/cute-dogs-server/dog-image-view.html @@ -0,0 +1,12 @@ + + + + + + MCP Show Dog Image + + +
+ + + diff --git a/examples/cute-dogs-server/helpers.ts b/examples/cute-dogs-server/helpers.ts new file mode 100644 index 00000000..deecf42b --- /dev/null +++ b/examples/cute-dogs-server/helpers.ts @@ -0,0 +1,136 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + CallToolResult, + ReadResourceResult, + Resource, +} from "@modelcontextprotocol/sdk/types.js"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const distDir = path.join(__dirname, "dist"); + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Load HTML file from dist directory + */ +export const loadHtml = async (name: string): Promise => { + const htmlPath = path.join(distDir, `${name}.html`); + return fs.readFile(htmlPath, "utf-8"); +}; + +/** + * Create an error result for tool calls + */ +export const createErrorResult = ( + error: unknown, + message: string, +): CallToolResult => { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + error: error instanceof Error ? error.message : message, + }), + }, + ], + isError: true, + }; +}; + +// ============================================================================ +// Dog CEO API Helpers +// ============================================================================ + +interface DogApiResponse { + status: string; + message: string | string[]; +} + +/** + * Fetch a random dog image (optionally for a specific breed) + */ +export const fetchRandomDogImage = async ( + breed?: string, +): Promise<{ message: string; breed: string }> => { + const apiUrl = breed + ? `https://dog.ceo/api/breed/${encodeURIComponent(breed)}/images/random` + : "https://dog.ceo/api/breeds/image/random"; + + const response = await fetch(apiUrl); + const data = (await response.json()) as DogApiResponse; + + if (data.status !== "success" || !data.message) { + throw new Error("Failed to fetch dog image"); + } + + const dogBreed = (data.message as string).split("/")[4]; + return { message: data.message as string, breed: dogBreed }; +}; + +/** + * Fetch all dog breeds + */ +export const fetchAllBreeds = async (): Promise => { + const response = await fetch("https://dog.ceo/api/breeds/list/all"); + const data = (await response.json()) as DogApiResponse; + + if (data.status !== "success" || typeof data.message !== "object") { + throw new Error("Failed to fetch breeds"); + } + + return Object.keys(data.message); +}; + +/** + * Fetch multiple random images for a breed + */ +export const fetchBreedImages = async ( + breed: string, + count: number, +): Promise => { + const apiUrl = `https://dog.ceo/api/breed/${encodeURIComponent(breed)}/images/random/${count}`; + const response = await fetch(apiUrl); + const data = (await response.json()) as DogApiResponse; + + if (data.status !== "success" || !Array.isArray(data.message)) { + throw new Error("Failed to fetch images"); + } + + return data.message; +}; + +// ============================================================================ +// Resource Registration +// ============================================================================ + +/** + * Register a UI resource with the server + */ +export const registerResource = ( + server: McpServer, + resource: Resource, + htmlContent: string, +): Resource => { + server.registerResource( + resource.name, + resource.uri, + resource, + async (): Promise => ({ + contents: [ + { + uri: resource.uri, + mimeType: resource.mimeType, + text: htmlContent, + }, + ], + }), + ); + return resource; +}; diff --git a/examples/cute-dogs-server/package.json b/examples/cute-dogs-server/package.json new file mode 100644 index 00000000..5e1ba9bb --- /dev/null +++ b/examples/cute-dogs-server/package.json @@ -0,0 +1,39 @@ +{ + "name": "cute-dogs-server", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "NODE_ENV=development npm run build && npm run server", + "build": "concurrently 'INPUT=dog-image-view.html vite build' 'INPUT=all-breeds-view.html vite build'", + "server": "bun server.ts" + }, + "dependencies": { + "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/sdk": "^1.22.0", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "lucide-react": "^0.454.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.25.0" + }, + "devDependencies": { + "@types/express": "^5.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "concurrently": "^9.2.1", + "cors": "^2.8.5", + "express": "^5.1.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "typescript": "^5.7.2", + "vite": "^6.0.0", + "vite-plugin-singlefile": "^2.3.0" + } +} diff --git a/examples/cute-dogs-server/postcss.config.js b/examples/cute-dogs-server/postcss.config.js new file mode 100644 index 00000000..2aa7205d --- /dev/null +++ b/examples/cute-dogs-server/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/examples/cute-dogs-server/server.ts b/examples/cute-dogs-server/server.ts new file mode 100644 index 00000000..4b5d6c53 --- /dev/null +++ b/examples/cute-dogs-server/server.ts @@ -0,0 +1,301 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import express, { Request, Response } from "express"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js"; +import cors from "cors"; +import { RESOURCE_URI_META_KEY } from "@modelcontextprotocol/ext-apps"; +import { + loadHtml, + registerResource, + fetchRandomDogImage, + fetchAllBreeds, + fetchBreedImages, + createErrorResult, +} from "./helpers.js"; + +// ============================================================================ +// Server Setup +// ============================================================================ + +/** + * Create and configure the MCP server + */ +const getServer = async () => { + const server = new McpServer( + { + name: "cute-dogs-mcp-server", + version: "1.0.0", + icons: [ + { + src: "https://dog.ceo/img/dog-api-logo.svg", + mimeType: "image/svg+xml", + }, + ], + }, + { + capabilities: { logging: {} }, + instructions: + "This server shows images of dogs. View all dog breeds with the `all-breeds-view` tool and widget. The dog-image-view tool and widget shows the image. The dog-image-view widget can call the `get-more-images` tool to get more images of the same breed.", + }, + ); + + // Load HTML files + const [showDogImageHtml, showAllBreedsHtml] = await Promise.all([ + loadHtml("dog-image-view"), + loadHtml("all-breeds-view"), + ]); + + // Register resources + const randomDogResource = registerResource( + server, + { + name: "show-random-dog-image-template", + uri: "ui://show-random-dog-image", + title: "Show Dog Image Template", + description: "A show dog image UI", + mimeType: "text/html+mcp", + }, + showDogImageHtml, + ); + + const showAllBreedsResource = registerResource( + server, + { + name: "all-breeds-view-template", + uri: "ui://all-breeds-view", + title: "Show All Breeds Template", + description: "A show all breeds UI", + mimeType: "text/html+mcp", + }, + showAllBreedsHtml, + ); + + // Register tools + server.registerTool( + "show-random-dog-image", + { + title: "Show Dog Image", + description: + "Show a dog image in an interactive UI widget. Do not show the image in the text response. The image will be shown in the UI widget.", + inputSchema: { + breed: z + .string() + .optional() + .describe( + "Optional dog breed (e.g., 'hound', 'retriever'). If not provided, returns a random dog from any breed.", + ), + }, + _meta: { + [RESOURCE_URI_META_KEY]: randomDogResource.uri, + }, + }, + async ({ breed }) => { + try { + const result = await fetchRandomDogImage(breed); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + message: `Successfully fetched ${breed} image`, + status: "success", + }), + }, + ], + structuredContent: { ...result, status: "success" }, + }; + } catch (error) { + return createErrorResult(error, "Failed to fetch dog image"); + } + }, + ); + + server.registerTool( + "all-breeds-view", + { + title: "Show All Breeds", + description: "Show all breeds in an interactive UI widget.", + _meta: { + [RESOURCE_URI_META_KEY]: showAllBreedsResource.uri, + }, + }, + async () => { + try { + const breeds = await fetchAllBreeds(); + return { + content: [ + { type: "text" as const, text: JSON.stringify({ breeds }) }, + ], + structuredContent: { breeds }, + }; + } catch (error) { + return createErrorResult(error, "Failed to fetch breeds"); + } + }, + ); + + server.registerTool( + "get-more-images", + { + title: "Get More Images", + description: + "Get multiple random dog images from a specific breed. Returns an array of image URLs.", + inputSchema: { + breed: z + .string() + .describe( + "The dog breed name (e.g., 'hound', 'retriever'). Required parameter.", + ), + count: z + .number() + .int() + .min(1) + .max(30) + .optional() + .default(3) + .describe( + "Number of images to fetch (1-30). Defaults to 3 if not provided.", + ), + }, + }, + async ({ breed, count = 3 }) => { + try { + const images = await fetchBreedImages(breed, count); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ breed, images }), + }, + ], + structuredContent: { breed, images }, + }; + } catch (error) { + return createErrorResult(error, "Failed to fetch images"); + } + }, + ); + + return server; +}; + +// ============================================================================ +// Express Server Setup +// ============================================================================ + +const MCP_PORT = process.env.MCP_PORT + ? parseInt(process.env.MCP_PORT, 10) + : 3001; + +const app = express(); +app.use(express.json()); +app.use( + cors({ + origin: "*", + exposedHeaders: ["Mcp-Session-Id"], + }), +); + +// Map to store transports by session ID +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +const mcpPostHandler = async (req: Request, res: Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + if (sessionId && transports[sessionId]) { + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, + onsessioninitialized: (sessionId) => { + console.log(`Session initialized: ${sessionId}`); + transports[sessionId] = transport; + }, + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && transports[sid]) { + console.log(`Session closed: ${sid}`); + delete transports[sid]; + } + }; + + const server = await getServer(); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } else { + res.status(400).json({ + jsonrpc: "2.0", + error: { code: -32000, message: "Bad Request: No valid session ID" }, + id: null, + }); + return; + } + + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null, + }); + } + } +}; + +app.post("/mcp", mcpPostHandler); + +app.get("/mcp", async (req: Request, res: Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send("Invalid or missing session ID"); + return; + } + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +app.delete("/mcp", async (req: Request, res: Response) => { + const sessionId = req.headers["mcp-session-id"] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send("Invalid or missing session ID"); + return; + } + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error("Error handling session termination:", error); + if (!res.headersSent) { + res.status(500).send("Error processing session termination"); + } + } +}); + +app.listen(MCP_PORT, () => { + console.log(`MCP Server listening on http://localhost:${MCP_PORT}/mcp`); +}); + +process.on("SIGINT", async () => { + console.log("Shutting down..."); + for (const sessionId in transports) { + try { + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing session ${sessionId}:`, error); + } + } + process.exit(0); +}); diff --git a/examples/cute-dogs-server/src/all-breeds-view.tsx b/examples/cute-dogs-server/src/all-breeds-view.tsx new file mode 100644 index 00000000..6870d203 --- /dev/null +++ b/examples/cute-dogs-server/src/all-breeds-view.tsx @@ -0,0 +1,104 @@ +/** + * @file App that displays all dog breeds and allows selecting one to show in chat. + */ +import "./styles.css"; +import { useState, useCallback } from "react"; +import { createRoot } from "react-dom/client"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; +import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +const APP_INFO: Implementation = { + name: "Show All Breeds App", + version: "1.0.0", +}; + +export function AllBreedsViewApp() { + const [breeds, setBreeds] = useState([]); + const [selectedBreed, setSelectedBreed] = useState(null); + + const { app } = useApp({ + appInfo: APP_INFO, + capabilities: {}, + onAppCreated: (app) => { + app.ontoolresult = async (toolResult) => { + const breeds = (toolResult.structuredContent as { breeds?: string[] }) + ?.breeds; + if (breeds) setBreeds(breeds); + }; + }, + }); + + const handleBreedClick = useCallback( + async (breed: string) => { + if (!app) return; + setSelectedBreed(breed); + try { + await app.sendMessage({ + role: "user", + content: [{ type: "text", text: `Show me a ${breed}` }], + }); + } catch (e) { + console.error("Failed to send message:", e); + } + }, + [app], + ); + + return ( +
+
+
+

Dog Breeds

+

+ Select a breed to view in chat +

+
+ + {selectedBreed && ( + + + + {selectedBreed} + + Message sent to chat + + + )} + + {breeds.length === 0 ? ( + + +

Loading breeds...

+
+
+ ) : ( +
+ {breeds.map((breed) => ( + + ))} +
+ )} +
+
+ ); +} + +window.addEventListener("load", () => { + const root = document.getElementById("root"); + if (!root) { + throw new Error("Root element not found"); + } + + createRoot(root).render(); +}); diff --git a/examples/cute-dogs-server/src/components/ui/alert.tsx b/examples/cute-dogs-server/src/components/ui/alert.tsx new file mode 100644 index 00000000..d964627c --- /dev/null +++ b/examples/cute-dogs-server/src/components/ui/alert.tsx @@ -0,0 +1,58 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/examples/cute-dogs-server/src/components/ui/badge.tsx b/examples/cute-dogs-server/src/components/ui/badge.tsx new file mode 100644 index 00000000..b6faebca --- /dev/null +++ b/examples/cute-dogs-server/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends + React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/examples/cute-dogs-server/src/components/ui/button.tsx b/examples/cute-dogs-server/src/components/ui/button.tsx new file mode 100644 index 00000000..75c42185 --- /dev/null +++ b/examples/cute-dogs-server/src/components/ui/button.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends + React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => { + return ( + +
+ +
+ {dogBreed} +
+ + + + {hasFetchedMoreImages && moreImages.length === 0 && ( + + + Failed to load more images. + + + )} + + + + {moreImages.length > 0 && ( +
+
+

+ More {dogBreed} Images +

+ {imageCount} total +
+
+ {moreImages.map((imageUrl, index) => ( + +
+ {`${dogBreed} +
+
+ ))} +
+
+ )} +
+ ) : ( + + +

Waiting for dog image...

+
+
+ )} +
+ + ); +} + +window.addEventListener("load", () => { + const root = document.getElementById("root"); + if (!root) { + throw new Error("Root element not found"); + } + + createRoot(root).render(); +}); diff --git a/examples/cute-dogs-server/src/lib/utils.ts b/examples/cute-dogs-server/src/lib/utils.ts new file mode 100644 index 00000000..a5ef1935 --- /dev/null +++ b/examples/cute-dogs-server/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/examples/cute-dogs-server/src/styles.css b/examples/cute-dogs-server/src/styles.css new file mode 100644 index 00000000..88890ba5 --- /dev/null +++ b/examples/cute-dogs-server/src/styles.css @@ -0,0 +1,60 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + diff --git a/examples/cute-dogs-server/tailwind.config.js b/examples/cute-dogs-server/tailwind.config.js new file mode 100644 index 00000000..fe62d723 --- /dev/null +++ b/examples/cute-dogs-server/tailwind.config.js @@ -0,0 +1,50 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: ["class"], + content: ["./src/**/*.{ts,tsx}", "./*.html"], + theme: { + extend: { + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + colors: { + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; diff --git a/examples/cute-dogs-server/tsconfig.json b/examples/cute-dogs-server/tsconfig.json new file mode 100644 index 00000000..e5a86550 --- /dev/null +++ b/examples/cute-dogs-server/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/examples/cute-dogs-server/vite.config.ts b/examples/cute-dogs-server/vite.config.ts new file mode 100644 index 00000000..7876e40e --- /dev/null +++ b/examples/cute-dogs-server/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { viteSingleFile } from "vite-plugin-singlefile"; +import path from "path"; + +const INPUT = process.env.INPUT; +if (!INPUT) { + throw new Error("INPUT environment variable is not set"); +} + +const isDevelopment = process.env.NODE_ENV === "development"; + +export default defineConfig({ + plugins: [react(), viteSingleFile()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + build: { + sourcemap: isDevelopment ? "inline" : undefined, + cssMinify: !isDevelopment, + minify: !isDevelopment, + + rollupOptions: { + input: INPUT, + }, + outDir: `dist`, + emptyOutDir: false, + }, +});