create-mcp-use-app 2.0.0-beta.2 → 2.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/index.js +10 -4
- package/dist/templates/blank/package.json +9 -5
- package/dist/templates/blank/tsconfig.json +13 -10
- package/dist/templates/mcp-apps/index.ts +119 -15
- package/dist/templates/mcp-apps/{tools.d.ts → mcp-env.d.ts} +3 -1
- package/dist/templates/mcp-apps/package.json +9 -10
- package/dist/templates/mcp-apps/public/fruits/apple.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/apricot.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/avocado.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/banana.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/blueberry.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/cherries.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/coconut.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/grapes.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/lemon.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/mango.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/orange.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/pear.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/pineapple.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/plum.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/strawberry.webp +0 -0
- package/dist/templates/mcp-apps/public/fruits/watermelon.webp +0 -0
- package/dist/templates/mcp-apps/tsconfig.json +9 -4
- package/dist/templates/mcp-apps/views/product-search-result/components/Accordion.tsx +35 -0
- package/dist/templates/mcp-apps/views/product-search-result/components/AccordionItem.tsx +35 -0
- package/dist/templates/mcp-apps/views/product-search-result/components/Carousel.tsx +52 -0
- package/dist/templates/mcp-apps/views/product-search-result/components/CarouselItem.tsx +91 -0
- package/dist/templates/mcp-apps/views/product-search-result/components/CarouselSkeleton.tsx +18 -0
- package/dist/templates/mcp-apps/views/product-search-result/hooks/useCarouselAnimation.ts +72 -0
- package/dist/templates/mcp-apps/views/product-search-result/view.css +81 -0
- package/dist/templates/mcp-apps/views/product-search-result/view.tsx +371 -0
- package/dist/templates/starter/package.json +9 -8
- package/dist/templates/starter/tsconfig.json +13 -10
- package/package.json +1 -1
- package/dist/templates/blank/public/favicon.ico +0 -0
- package/dist/templates/mcp-apps/public/favicon.ico +0 -0
- package/dist/templates/mcp-apps/public/fruits/apple.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/apricot.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/avocado.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/banana.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/blueberry.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/cherries.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/coconut.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/grapes.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/lemon.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/mango.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/orange.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/pear.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/pineapple.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/plum.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/strawberry.png +0 -0
- package/dist/templates/mcp-apps/public/fruits/watermelon.png +0 -0
- package/dist/templates/mcp-apps/resources/product-search-result/view.tsx +0 -25
- package/dist/templates/starter/public/favicon.ico +0 -0
package/README.md
CHANGED
|
@@ -156,8 +156,8 @@ Perfect for getting started with all available features or building full-feature
|
|
|
156
156
|
The mcp-apps template includes:
|
|
157
157
|
|
|
158
158
|
- MCP server setup focused on OpenAI Apps SDK integration
|
|
159
|
-
- OpenAI Apps SDK compatible
|
|
160
|
-
- Example
|
|
159
|
+
- OpenAI Apps SDK compatible views under `views/<name>/view.tsx`
|
|
160
|
+
- Example `product-search-result` view with carousel UI
|
|
161
161
|
- Optimized for OpenAI assistant integration
|
|
162
162
|
|
|
163
163
|
Ideal for building MCP servers that integrate with OpenAI's Apps SDK.
|
package/dist/index.js
CHANGED
|
@@ -1321,7 +1321,7 @@ function updatePackageJson(projectPath, projectName) {
|
|
|
1321
1321
|
const packageJsonPath = join2(projectPath, "package.json");
|
|
1322
1322
|
const packageJsonContent = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
1323
1323
|
packageJsonContent.name = projectName;
|
|
1324
|
-
packageJsonContent.description =
|
|
1324
|
+
packageJsonContent.description = `${projectName}: an mcp-use server`;
|
|
1325
1325
|
writeFileSync(packageJsonPath, JSON.stringify(packageJsonContent, null, 2));
|
|
1326
1326
|
}
|
|
1327
1327
|
function updateIndexTs(projectPath, projectName) {
|
|
@@ -2152,10 +2152,16 @@ async function main() {
|
|
|
2152
2152
|
console.log(" \u251C\u2500\u2500 tsconfig.json");
|
|
2153
2153
|
console.log(" \u2514\u2500\u2500 README.md");
|
|
2154
2154
|
} else if (validatedTemplate === "mcp-apps") {
|
|
2155
|
-
console.log(" \u251C\u2500\u2500
|
|
2155
|
+
console.log(" \u251C\u2500\u2500 public/");
|
|
2156
|
+
console.log(" \u2502 \u251C\u2500\u2500 icon.svg");
|
|
2157
|
+
console.log(" \u2502 \u2514\u2500\u2500 fruits/ (16 WebP assets)");
|
|
2158
|
+
console.log(" \u251C\u2500\u2500 views/");
|
|
2156
2159
|
console.log(" \u2502 \u2514\u2500\u2500 product-search-result/");
|
|
2157
|
-
console.log(" \u2502 \
|
|
2158
|
-
console.log(" \u251C\u2500\u2500
|
|
2160
|
+
console.log(" \u2502 \u251C\u2500\u2500 view.tsx");
|
|
2161
|
+
console.log(" \u2502 \u251C\u2500\u2500 view.css");
|
|
2162
|
+
console.log(" \u2502 \u251C\u2500\u2500 components/");
|
|
2163
|
+
console.log(" \u2502 \u2514\u2500\u2500 hooks/");
|
|
2164
|
+
console.log(" \u251C\u2500\u2500 mcp-env.d.ts");
|
|
2159
2165
|
console.log(" \u251C\u2500\u2500 index.ts (server entry point)");
|
|
2160
2166
|
console.log(" \u251C\u2500\u2500 package.json");
|
|
2161
2167
|
console.log(" \u251C\u2500\u2500 tsconfig.json");
|
|
@@ -5,14 +5,18 @@
|
|
|
5
5
|
"node": ">=22.22.2"
|
|
6
6
|
},
|
|
7
7
|
"version": "1.0.0",
|
|
8
|
-
"description": "
|
|
9
|
-
"author": "",
|
|
8
|
+
"description": "an mcp-use server",
|
|
9
|
+
"author": "mcp-use",
|
|
10
10
|
"license": "MIT",
|
|
11
|
+
"homepage": "https://github.com/mcp-use/mcp-use",
|
|
11
12
|
"keywords": [
|
|
12
13
|
"mcp",
|
|
13
|
-
"server",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
14
|
+
"mcp-server",
|
|
15
|
+
"chatgpt plugins",
|
|
16
|
+
"claude connector",
|
|
17
|
+
"mcp-app",
|
|
18
|
+
"mcp-use",
|
|
19
|
+
"manufact"
|
|
16
20
|
],
|
|
17
21
|
"main": "dist/index.js",
|
|
18
22
|
"scripts": {
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
|
-
"target": "
|
|
3
|
+
"target": "ES2024",
|
|
4
4
|
"jsx": "react-jsx",
|
|
5
|
-
"module": "
|
|
6
|
-
"moduleResolution": "
|
|
7
|
-
"
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
|
8
8
|
"strict": true,
|
|
9
|
-
"outDir": "./dist",
|
|
10
|
-
"sourceMap": true,
|
|
11
|
-
"allowSyntheticDefaultImports": true,
|
|
12
9
|
"esModuleInterop": true,
|
|
13
10
|
"forceConsistentCasingInFileNames": true,
|
|
14
11
|
"skipLibCheck": true,
|
|
15
|
-
"
|
|
16
|
-
"tsBuildInfoFile": "./dist/.tsbuildinfo"
|
|
12
|
+
"noEmit": true
|
|
17
13
|
},
|
|
18
|
-
"include": [
|
|
14
|
+
"include": [
|
|
15
|
+
"index.ts",
|
|
16
|
+
"mcp-env.d.ts",
|
|
17
|
+
"src/**/*",
|
|
18
|
+
"views/**/*",
|
|
19
|
+
"server.ts",
|
|
20
|
+
".mcp-use/**/*.d.ts"
|
|
21
|
+
],
|
|
19
22
|
"exclude": ["node_modules", "dist"]
|
|
20
23
|
}
|
|
@@ -1,43 +1,147 @@
|
|
|
1
1
|
import { MCPServer } from "mcp-use";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
|
-
const fruits = [
|
|
4
|
+
const fruits = [
|
|
5
|
+
{ fruit: "mango", color: "bg-[#FBF1E1] dark:bg-[#FBF1E1]/10" },
|
|
6
|
+
{ fruit: "pineapple", color: "bg-[#f8f0d9] dark:bg-[#f8f0d9]/10" },
|
|
7
|
+
{ fruit: "cherries", color: "bg-[#E2EDDC] dark:bg-[#E2EDDC]/10" },
|
|
8
|
+
{ fruit: "coconut", color: "bg-[#fbedd3] dark:bg-[#fbedd3]/10" },
|
|
9
|
+
{ fruit: "apricot", color: "bg-[#fee6ca] dark:bg-[#fee6ca]/10" },
|
|
10
|
+
{ fruit: "blueberry", color: "bg-[#e0e6e6] dark:bg-[#e0e6e6]/10" },
|
|
11
|
+
{ fruit: "grapes", color: "bg-[#f4ebe2] dark:bg-[#f4ebe2]/10" },
|
|
12
|
+
{ fruit: "watermelon", color: "bg-[#e6eddb] dark:bg-[#e6eddb]/10" },
|
|
13
|
+
{ fruit: "orange", color: "bg-[#fdebdf] dark:bg-[#fdebdf]/10" },
|
|
14
|
+
{ fruit: "avocado", color: "bg-[#ecefda] dark:bg-[#ecefda]/10" },
|
|
15
|
+
{ fruit: "apple", color: "bg-[#F9E7E4] dark:bg-[#F9E7E4]/10" },
|
|
16
|
+
{ fruit: "pear", color: "bg-[#f1f1cf] dark:bg-[#f1f1cf]/10" },
|
|
17
|
+
{ fruit: "plum", color: "bg-[#ece5ec] dark:bg-[#ece5ec]/10" },
|
|
18
|
+
{ fruit: "banana", color: "bg-[#fdf0dd] dark:bg-[#fdf0dd]/10" },
|
|
19
|
+
{ fruit: "strawberry", color: "bg-[#f7e6df] dark:bg-[#f7e6df]/10" },
|
|
20
|
+
{ fruit: "lemon", color: "bg-[#feeecd] dark:bg-[#feeecd]/10" },
|
|
21
|
+
];
|
|
5
22
|
|
|
6
|
-
const
|
|
23
|
+
const fruitRowSchema = z.object({
|
|
24
|
+
fruit: z.string(),
|
|
25
|
+
color: z.string(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const searchOutputSchema = z.object({
|
|
7
29
|
query: z.string(),
|
|
8
|
-
|
|
30
|
+
results: z.array(fruitRowSchema),
|
|
9
31
|
});
|
|
10
32
|
|
|
33
|
+
const detailsOutputSchema = z.object({
|
|
34
|
+
fruit: z.string(),
|
|
35
|
+
color: z.string(),
|
|
36
|
+
facts: z.array(z.string()),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function renderAsMarkdownTable(
|
|
40
|
+
results: Array<{ fruit: string; color: string }>
|
|
41
|
+
): string {
|
|
42
|
+
const header = "| Fruit | Color |\n| --- | --- |";
|
|
43
|
+
const rows = results.map((r) => `| ${r.fruit} | ${r.color} |`).join("\n");
|
|
44
|
+
return `${header}\n${rows}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
11
47
|
const server = new MCPServer({
|
|
12
48
|
name: "{{PROJECT_NAME}}",
|
|
13
49
|
title: "{{PROJECT_NAME}}",
|
|
14
50
|
version: "1.0.0",
|
|
15
|
-
description: "
|
|
51
|
+
description: "MCP Apps server with a fruit shop view",
|
|
52
|
+
instructions:
|
|
53
|
+
"Use search-fruits to find fruit matches before calling get-fruit-details. Prefer the view result when the user wants to browse or compare options visually.",
|
|
54
|
+
websiteUrl: "https://mcp-use.com",
|
|
55
|
+
icons: [
|
|
56
|
+
{
|
|
57
|
+
src: "icon.svg",
|
|
58
|
+
mimeType: "image/svg+xml",
|
|
59
|
+
sizes: ["512x512"],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
16
62
|
});
|
|
17
63
|
|
|
18
64
|
export const searchFruits = server.tool(
|
|
19
65
|
{
|
|
20
66
|
name: "search-fruits",
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
67
|
+
title: "Search fruits",
|
|
68
|
+
description: "Search for fruits and display the results in a view",
|
|
69
|
+
inputSchema: z.object({
|
|
70
|
+
query: z.string().optional().describe("Search query to filter fruits"),
|
|
71
|
+
}),
|
|
72
|
+
outputSchema: searchOutputSchema,
|
|
24
73
|
view: {
|
|
25
74
|
name: "product-search-result",
|
|
26
|
-
description: "
|
|
75
|
+
description: "Product search results with carousel and details",
|
|
27
76
|
prefersBorder: true,
|
|
28
77
|
},
|
|
29
|
-
annotations: {
|
|
78
|
+
annotations: {
|
|
79
|
+
readOnlyHint: true,
|
|
80
|
+
destructiveHint: false,
|
|
81
|
+
openWorldHint: false,
|
|
82
|
+
},
|
|
30
83
|
},
|
|
31
|
-
async ({ query = "" }) => {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
84
|
+
async ({ query = "" }, ctx) => {
|
|
85
|
+
const results = fruits.filter(
|
|
86
|
+
(f) => query === "" || f.fruit.toLowerCase().includes(query.toLowerCase())
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
// Emulate a delay to show the loading state in the view
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
91
|
+
|
|
92
|
+
const data = { query, results };
|
|
93
|
+
|
|
94
|
+
if (!ctx.client.supportsViews()) {
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text", text: renderAsMarkdownTable(results) }],
|
|
97
|
+
structuredContent: data,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
36
101
|
return {
|
|
37
|
-
content: [
|
|
102
|
+
content: [
|
|
103
|
+
{
|
|
104
|
+
type: "text",
|
|
105
|
+
text: `Found ${results.length} fruits matching "${query || "all"}"`,
|
|
106
|
+
},
|
|
107
|
+
],
|
|
38
108
|
structuredContent: data,
|
|
39
109
|
};
|
|
40
110
|
}
|
|
41
111
|
);
|
|
42
112
|
|
|
113
|
+
export const getFruitDetails = server.tool(
|
|
114
|
+
{
|
|
115
|
+
name: "get-fruit-details",
|
|
116
|
+
title: "Get fruit details",
|
|
117
|
+
description: "Get detailed information about a specific fruit",
|
|
118
|
+
inputSchema: z.object({
|
|
119
|
+
fruit: z.string().describe("The fruit name"),
|
|
120
|
+
}),
|
|
121
|
+
outputSchema: detailsOutputSchema,
|
|
122
|
+
annotations: {
|
|
123
|
+
readOnlyHint: true,
|
|
124
|
+
destructiveHint: false,
|
|
125
|
+
openWorldHint: false,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
async ({ fruit }) => {
|
|
129
|
+
const found = fruits.find(
|
|
130
|
+
(f) => f.fruit.toLowerCase() === fruit.toLowerCase()
|
|
131
|
+
);
|
|
132
|
+
const details = {
|
|
133
|
+
fruit: found?.fruit ?? fruit,
|
|
134
|
+
color: found?.color ?? "unknown",
|
|
135
|
+
facts: [
|
|
136
|
+
`${fruit} is a delicious fruit`,
|
|
137
|
+
`Color: ${found?.color ?? "unknown"}`,
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
142
|
+
structuredContent: details,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
|
|
43
147
|
export default server;
|
|
@@ -5,19 +5,18 @@
|
|
|
5
5
|
"node": ">=22.22.2"
|
|
6
6
|
},
|
|
7
7
|
"version": "1.0.0",
|
|
8
|
-
"description": "
|
|
9
|
-
"author": "",
|
|
8
|
+
"description": "an mcp-use server",
|
|
9
|
+
"author": "mcp-use",
|
|
10
10
|
"license": "MIT",
|
|
11
|
+
"homepage": "https://github.com/mcp-use/mcp-use",
|
|
11
12
|
"keywords": [
|
|
12
13
|
"mcp",
|
|
13
|
-
"server",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"react",
|
|
20
|
-
"mcp-apps"
|
|
14
|
+
"mcp-server",
|
|
15
|
+
"chatgpt plugins",
|
|
16
|
+
"claude connector",
|
|
17
|
+
"mcp-app",
|
|
18
|
+
"mcp-use",
|
|
19
|
+
"manufact"
|
|
21
20
|
],
|
|
22
21
|
"main": "dist/index.js",
|
|
23
22
|
"scripts": {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -9,10 +9,15 @@
|
|
|
9
9
|
"esModuleInterop": true,
|
|
10
10
|
"forceConsistentCasingInFileNames": true,
|
|
11
11
|
"skipLibCheck": true,
|
|
12
|
-
"noEmit": true
|
|
13
|
-
"noUncheckedIndexedAccess": true,
|
|
14
|
-
"exactOptionalPropertyTypes": true
|
|
12
|
+
"noEmit": true
|
|
15
13
|
},
|
|
16
|
-
"include": [
|
|
14
|
+
"include": [
|
|
15
|
+
"index.ts",
|
|
16
|
+
"mcp-env.d.ts",
|
|
17
|
+
"src/**/*",
|
|
18
|
+
"views/**/*",
|
|
19
|
+
"server.ts",
|
|
20
|
+
".mcp-use/**/*.d.ts"
|
|
21
|
+
],
|
|
17
22
|
"exclude": ["node_modules", "dist"]
|
|
18
23
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { AccordionItem } from "./AccordionItem.js";
|
|
3
|
+
|
|
4
|
+
export interface AccordionItemData {
|
|
5
|
+
question: string;
|
|
6
|
+
answer: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface AccordionProps {
|
|
10
|
+
items: AccordionItemData[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Accordion({ items }: AccordionProps) {
|
|
14
|
+
const [openAccordionIndex, setOpenAccordionIndex] = useState<number | null>(
|
|
15
|
+
null
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="mt-4 border-t border-neutral-200 p-8 pt-4 dark:border-neutral-700">
|
|
20
|
+
<div className="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-700">
|
|
21
|
+
{items.map((item, index) => (
|
|
22
|
+
<AccordionItem
|
|
23
|
+
key={index}
|
|
24
|
+
question={item.question}
|
|
25
|
+
answer={item.answer}
|
|
26
|
+
isOpen={openAccordionIndex === index}
|
|
27
|
+
onToggle={() =>
|
|
28
|
+
setOpenAccordionIndex(openAccordionIndex === index ? null : index)
|
|
29
|
+
}
|
|
30
|
+
/>
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface AccordionItemProps {
|
|
2
|
+
question: string;
|
|
3
|
+
answer: string;
|
|
4
|
+
isOpen: boolean;
|
|
5
|
+
onToggle: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function AccordionItem({
|
|
9
|
+
question,
|
|
10
|
+
answer,
|
|
11
|
+
isOpen,
|
|
12
|
+
onToggle,
|
|
13
|
+
}: AccordionItemProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="border-b border-neutral-200 last:border-b-0 dark:border-neutral-700">
|
|
16
|
+
<button
|
|
17
|
+
type="button"
|
|
18
|
+
onClick={onToggle}
|
|
19
|
+
className="flex w-full items-center justify-between p-4 text-left transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-900"
|
|
20
|
+
>
|
|
21
|
+
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
|
22
|
+
{question}
|
|
23
|
+
</span>
|
|
24
|
+
<span className="text-xl text-neutral-500 transition-transform duration-200 dark:text-neutral-400">
|
|
25
|
+
{isOpen ? "−" : "+"}
|
|
26
|
+
</span>
|
|
27
|
+
</button>
|
|
28
|
+
{isOpen && (
|
|
29
|
+
<div className="px-4 pb-4 text-neutral-600 dark:text-neutral-400">
|
|
30
|
+
{answer}
|
|
31
|
+
</div>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import { CarouselItem } from "./CarouselItem.js";
|
|
3
|
+
import { useCarouselAnimation } from "../hooks/useCarouselAnimation.js";
|
|
4
|
+
|
|
5
|
+
interface CarouselProps {
|
|
6
|
+
results: Array<{ fruit: string; color: string }>;
|
|
7
|
+
favorites?: string[];
|
|
8
|
+
highlighted?: string | null;
|
|
9
|
+
onSelectFruit: (fruit: string) => void;
|
|
10
|
+
onToggleFavorite?: (fruit: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Carousel({
|
|
14
|
+
results,
|
|
15
|
+
favorites = [],
|
|
16
|
+
highlighted = null,
|
|
17
|
+
onSelectFruit,
|
|
18
|
+
onToggleFavorite,
|
|
19
|
+
}: CarouselProps) {
|
|
20
|
+
const carouselContainerRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
|
|
23
|
+
useCarouselAnimation(carouselContainerRef, scrollContainerRef);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
ref={scrollContainerRef}
|
|
28
|
+
className="carousel-scroll-container w-full overflow-x-auto overflow-y-visible pl-8"
|
|
29
|
+
>
|
|
30
|
+
<div ref={carouselContainerRef} className="overflow-visible">
|
|
31
|
+
<div className="flex gap-4">
|
|
32
|
+
{results.map((item, index) => {
|
|
33
|
+
const fruit = item.fruit;
|
|
34
|
+
return (
|
|
35
|
+
<CarouselItem
|
|
36
|
+
key={`${fruit}-${index}`}
|
|
37
|
+
fruit={fruit}
|
|
38
|
+
color={item.color}
|
|
39
|
+
isFavorite={favorites.includes(fruit)}
|
|
40
|
+
isHighlighted={highlighted === fruit}
|
|
41
|
+
onClick={() => onSelectFruit(fruit)}
|
|
42
|
+
{...(onToggleFavorite !== undefined && {
|
|
43
|
+
onToggleFavorite: () => onToggleFavorite(fruit),
|
|
44
|
+
})}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
})}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Image } from "mcp-use/react";
|
|
2
|
+
|
|
3
|
+
export interface CarouselItemProps {
|
|
4
|
+
fruit: string;
|
|
5
|
+
color: string;
|
|
6
|
+
isFavorite?: boolean;
|
|
7
|
+
isHighlighted?: boolean;
|
|
8
|
+
onClick: () => void;
|
|
9
|
+
onToggleFavorite?: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function HeartIcon({ filled }: { filled: boolean }) {
|
|
13
|
+
if (filled) {
|
|
14
|
+
return (
|
|
15
|
+
<svg
|
|
16
|
+
width="16"
|
|
17
|
+
height="16"
|
|
18
|
+
viewBox="0 0 24 24"
|
|
19
|
+
fill="currentColor"
|
|
20
|
+
aria-hidden
|
|
21
|
+
>
|
|
22
|
+
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
|
23
|
+
</svg>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return (
|
|
27
|
+
<svg
|
|
28
|
+
width="16"
|
|
29
|
+
height="16"
|
|
30
|
+
viewBox="0 0 24 24"
|
|
31
|
+
fill="none"
|
|
32
|
+
stroke="currentColor"
|
|
33
|
+
strokeWidth="2"
|
|
34
|
+
aria-hidden
|
|
35
|
+
>
|
|
36
|
+
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
|
37
|
+
</svg>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function CarouselItem({
|
|
42
|
+
fruit,
|
|
43
|
+
color,
|
|
44
|
+
isFavorite,
|
|
45
|
+
isHighlighted,
|
|
46
|
+
onClick,
|
|
47
|
+
onToggleFavorite,
|
|
48
|
+
}: CarouselItemProps) {
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className={`carousel-item shrink-0 size-52 rounded-xl border border-neutral-200 dark:border-neutral-700 ${color}${isHighlighted ? " carousel-item-highlighted" : ""}`}
|
|
52
|
+
onClick={onClick}
|
|
53
|
+
role="button"
|
|
54
|
+
tabIndex={0}
|
|
55
|
+
onKeyDown={(e) => {
|
|
56
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
onClick();
|
|
59
|
+
}
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{onToggleFavorite && (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
onClick={(e) => {
|
|
66
|
+
e.stopPropagation();
|
|
67
|
+
onToggleFavorite();
|
|
68
|
+
}}
|
|
69
|
+
className={`absolute top-2 right-2 z-10 rounded-full p-1.5 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
|
|
70
|
+
isFavorite
|
|
71
|
+
? "text-red-500"
|
|
72
|
+
: "text-neutral-500 dark:text-neutral-400"
|
|
73
|
+
}`}
|
|
74
|
+
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
|
75
|
+
>
|
|
76
|
+
<HeartIcon filled={isFavorite === true} />
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
79
|
+
<div className="carousel-item-bg">
|
|
80
|
+
<Image src={`/fruits/${fruit}.webp`} alt={fruit} />
|
|
81
|
+
</div>
|
|
82
|
+
<div className="carousel-item-content">
|
|
83
|
+
<Image
|
|
84
|
+
src={`/fruits/${fruit}.webp`}
|
|
85
|
+
alt={fruit}
|
|
86
|
+
className="h-24 w-24 object-contain"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const SKELETON_COUNT = 6;
|
|
2
|
+
|
|
3
|
+
export function CarouselSkeleton() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="carousel-scroll-container w-full overflow-x-auto overflow-y-visible pl-8">
|
|
6
|
+
<div className="overflow-hidden">
|
|
7
|
+
<div className="flex gap-4">
|
|
8
|
+
{Array.from({ length: SKELETON_COUNT }).map((_, i) => (
|
|
9
|
+
<div
|
|
10
|
+
key={i}
|
|
11
|
+
className="carousel-item shrink-0 size-52 animate-pulse rounded-xl border border-neutral-200 bg-neutral-100 dark:border-neutral-700 dark:bg-neutral-800"
|
|
12
|
+
/>
|
|
13
|
+
))}
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect, type RefObject } from "react";
|
|
2
|
+
|
|
3
|
+
export function useCarouselAnimation(
|
|
4
|
+
carouselContainerRef: RefObject<HTMLDivElement | null>,
|
|
5
|
+
scrollContainerRef: RefObject<HTMLDivElement | null>
|
|
6
|
+
): void {
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
let lastPointerX = 0;
|
|
9
|
+
let lastPointerY = 0;
|
|
10
|
+
|
|
11
|
+
const updateItems = () => {
|
|
12
|
+
const container = carouselContainerRef.current;
|
|
13
|
+
if (!container) return;
|
|
14
|
+
|
|
15
|
+
const articles =
|
|
16
|
+
container.querySelectorAll<HTMLElement>(".carousel-item");
|
|
17
|
+
|
|
18
|
+
articles.forEach((article) => {
|
|
19
|
+
const rect = article.getBoundingClientRect();
|
|
20
|
+
const centerX = rect.left + rect.width / 2;
|
|
21
|
+
const centerY = rect.top + rect.height / 2;
|
|
22
|
+
const relativeX = lastPointerX - centerX;
|
|
23
|
+
const relativeY = lastPointerY - centerY;
|
|
24
|
+
const x = relativeX / (rect.width / 2);
|
|
25
|
+
const y = relativeY / (rect.height / 2);
|
|
26
|
+
|
|
27
|
+
const distance = Math.sqrt(
|
|
28
|
+
relativeX * relativeX + relativeY * relativeY
|
|
29
|
+
);
|
|
30
|
+
const maxDistance = Math.max(rect.width, rect.height) * 2;
|
|
31
|
+
const normalizedDistance = Math.min(distance / maxDistance, 1);
|
|
32
|
+
const proximity = Math.pow(1 - normalizedDistance, 2);
|
|
33
|
+
const opacity = 0.1 + proximity * 0.3;
|
|
34
|
+
const scale = 2.0 + proximity * 2.0;
|
|
35
|
+
|
|
36
|
+
article.style.setProperty("--pointer-x", x.toFixed(3));
|
|
37
|
+
article.style.setProperty("--pointer-y", y.toFixed(3));
|
|
38
|
+
article.style.setProperty("--icon-opacity", opacity.toFixed(3));
|
|
39
|
+
article.style.setProperty("--icon-scale", scale.toFixed(2));
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handlePointerMove = (event: { clientX: number; clientY: number }) => {
|
|
44
|
+
lastPointerX = event.clientX;
|
|
45
|
+
lastPointerY = event.clientY;
|
|
46
|
+
updateItems();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const handleScroll = () => {
|
|
50
|
+
updateItems();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
document.addEventListener("pointermove", handlePointerMove);
|
|
54
|
+
window.addEventListener("scroll", handleScroll, true);
|
|
55
|
+
|
|
56
|
+
const scrollContainer = scrollContainerRef.current;
|
|
57
|
+
if (scrollContainer) {
|
|
58
|
+
scrollContainer.addEventListener("scroll", handleScroll);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
updateItems();
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
document.removeEventListener("pointermove", handlePointerMove);
|
|
65
|
+
window.removeEventListener("scroll", handleScroll, true);
|
|
66
|
+
const container = scrollContainerRef.current;
|
|
67
|
+
if (container) {
|
|
68
|
+
container.removeEventListener("scroll", handleScroll);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}, [carouselContainerRef, scrollContainerRef]);
|
|
72
|
+
}
|