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.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +10 -4
  3. package/dist/templates/blank/package.json +9 -5
  4. package/dist/templates/blank/tsconfig.json +13 -10
  5. package/dist/templates/mcp-apps/index.ts +119 -15
  6. package/dist/templates/mcp-apps/{tools.d.ts → mcp-env.d.ts} +3 -1
  7. package/dist/templates/mcp-apps/package.json +9 -10
  8. package/dist/templates/mcp-apps/public/fruits/apple.webp +0 -0
  9. package/dist/templates/mcp-apps/public/fruits/apricot.webp +0 -0
  10. package/dist/templates/mcp-apps/public/fruits/avocado.webp +0 -0
  11. package/dist/templates/mcp-apps/public/fruits/banana.webp +0 -0
  12. package/dist/templates/mcp-apps/public/fruits/blueberry.webp +0 -0
  13. package/dist/templates/mcp-apps/public/fruits/cherries.webp +0 -0
  14. package/dist/templates/mcp-apps/public/fruits/coconut.webp +0 -0
  15. package/dist/templates/mcp-apps/public/fruits/grapes.webp +0 -0
  16. package/dist/templates/mcp-apps/public/fruits/lemon.webp +0 -0
  17. package/dist/templates/mcp-apps/public/fruits/mango.webp +0 -0
  18. package/dist/templates/mcp-apps/public/fruits/orange.webp +0 -0
  19. package/dist/templates/mcp-apps/public/fruits/pear.webp +0 -0
  20. package/dist/templates/mcp-apps/public/fruits/pineapple.webp +0 -0
  21. package/dist/templates/mcp-apps/public/fruits/plum.webp +0 -0
  22. package/dist/templates/mcp-apps/public/fruits/strawberry.webp +0 -0
  23. package/dist/templates/mcp-apps/public/fruits/watermelon.webp +0 -0
  24. package/dist/templates/mcp-apps/tsconfig.json +9 -4
  25. package/dist/templates/mcp-apps/views/product-search-result/components/Accordion.tsx +35 -0
  26. package/dist/templates/mcp-apps/views/product-search-result/components/AccordionItem.tsx +35 -0
  27. package/dist/templates/mcp-apps/views/product-search-result/components/Carousel.tsx +52 -0
  28. package/dist/templates/mcp-apps/views/product-search-result/components/CarouselItem.tsx +91 -0
  29. package/dist/templates/mcp-apps/views/product-search-result/components/CarouselSkeleton.tsx +18 -0
  30. package/dist/templates/mcp-apps/views/product-search-result/hooks/useCarouselAnimation.ts +72 -0
  31. package/dist/templates/mcp-apps/views/product-search-result/view.css +81 -0
  32. package/dist/templates/mcp-apps/views/product-search-result/view.tsx +371 -0
  33. package/dist/templates/starter/package.json +9 -8
  34. package/dist/templates/starter/tsconfig.json +13 -10
  35. package/package.json +1 -1
  36. package/dist/templates/blank/public/favicon.ico +0 -0
  37. package/dist/templates/mcp-apps/public/favicon.ico +0 -0
  38. package/dist/templates/mcp-apps/public/fruits/apple.png +0 -0
  39. package/dist/templates/mcp-apps/public/fruits/apricot.png +0 -0
  40. package/dist/templates/mcp-apps/public/fruits/avocado.png +0 -0
  41. package/dist/templates/mcp-apps/public/fruits/banana.png +0 -0
  42. package/dist/templates/mcp-apps/public/fruits/blueberry.png +0 -0
  43. package/dist/templates/mcp-apps/public/fruits/cherries.png +0 -0
  44. package/dist/templates/mcp-apps/public/fruits/coconut.png +0 -0
  45. package/dist/templates/mcp-apps/public/fruits/grapes.png +0 -0
  46. package/dist/templates/mcp-apps/public/fruits/lemon.png +0 -0
  47. package/dist/templates/mcp-apps/public/fruits/mango.png +0 -0
  48. package/dist/templates/mcp-apps/public/fruits/orange.png +0 -0
  49. package/dist/templates/mcp-apps/public/fruits/pear.png +0 -0
  50. package/dist/templates/mcp-apps/public/fruits/pineapple.png +0 -0
  51. package/dist/templates/mcp-apps/public/fruits/plum.png +0 -0
  52. package/dist/templates/mcp-apps/public/fruits/strawberry.png +0 -0
  53. package/dist/templates/mcp-apps/public/fruits/watermelon.png +0 -0
  54. package/dist/templates/mcp-apps/resources/product-search-result/view.tsx +0 -25
  55. 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 widgets
160
- - Example display-weather widget
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 = `MCP server: ${projectName}`;
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 resources/");
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 \u2514\u2500\u2500 view.tsx");
2158
- console.log(" \u251C\u2500\u2500 tools.d.ts");
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": "Blank mcp-use server",
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
- "blank",
15
- "mcp-use"
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": "ES2022",
3
+ "target": "ES2024",
4
4
  "jsx": "react-jsx",
5
- "module": "ESNext",
6
- "moduleResolution": "bundler",
7
- "allowJs": true,
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
- "incremental": true,
16
- "tsBuildInfoFile": "./dist/.tsbuildinfo"
12
+ "noEmit": true
17
13
  },
18
- "include": ["index.ts", "src/**/*", "resources/**/*", "server.ts", ".mcp-use/**/*.d.ts"],
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 = ["apple", "banana", "mango", "orange", "strawberry"];
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 outputSchema = z.object({
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
- items: z.array(z.object({ id: z.string(), name: z.string() })),
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: "An MCP Apps server built with mcp-use",
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
- description: "Search the fruit catalog",
22
- inputSchema: z.object({ query: z.string().optional() }),
23
- outputSchema,
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: "Fruit search results",
75
+ description: "Product search results with carousel and details",
27
76
  prefersBorder: true,
28
77
  },
29
- annotations: { readOnlyHint: true, openWorldHint: false },
78
+ annotations: {
79
+ readOnlyHint: true,
80
+ destructiveHint: false,
81
+ openWorldHint: false,
82
+ },
30
83
  },
31
- async ({ query = "" }) => {
32
- const items = fruits
33
- .filter((fruit) => fruit.includes(query.toLowerCase()))
34
- .map((fruit) => ({ id: fruit, name: fruit }));
35
- const data = { query, items };
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: [{ type: "text", text: `Found ${items.length} fruits.` }],
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;
@@ -1,4 +1,6 @@
1
- // Created by mcp-use when missing. Existing files are never overwritten.
1
+ // mcp-use typing shim — dev/build recreate this file if missing.
2
+ declare module "*.css";
3
+
2
4
  declare module "mcp-use/react" {
3
5
  interface Register {
4
6
  tools: typeof import("./index.js");
@@ -5,19 +5,18 @@
5
5
  "node": ">=22.22.2"
6
6
  },
7
7
  "version": "1.0.0",
8
- "description": "MCP server with a standard MCP Apps view",
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
- "starter",
15
- "tools",
16
- "resources",
17
- "prompts",
18
- "ui",
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": {
@@ -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": ["index.ts", "tools.d.ts", "resources/**/*"],
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
+ }