mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
feat(chat): MCP card on the start screen, with the promo video until it is closed (#990)
* feat(chat): bring back the MCP server notice on the start screen * feat(chat): the start screen's MCP card plays the promo video until it is closed
This commit is contained in:
@@ -5,6 +5,7 @@ import { getAssetUrl } from "@/lib/base-path"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useChatEngine } from "./chat-engine"
|
||||
import { Composer } from "./composer"
|
||||
import { McpNotice } from "./mcp-notice"
|
||||
import { SessionMenu } from "./session-menu"
|
||||
|
||||
interface Example {
|
||||
@@ -78,6 +79,7 @@ export function LobbyHero({
|
||||
const dict = useDictionary()
|
||||
const t = dict.lobby
|
||||
const engine = useChatEngine()
|
||||
const isSelfHosted = process.env.NEXT_PUBLIC_SELFHOSTED === "true"
|
||||
|
||||
// Prompts and file names must stay as they are: cached responses match them
|
||||
const examples: Example[] = [
|
||||
@@ -173,6 +175,15 @@ export function LobbyHero({
|
||||
{t.lede}
|
||||
</p>
|
||||
|
||||
{/* MCP server notice, hidden on self-hosted deployments. The lede's
|
||||
margin already spans 24 px (20 px compact); pull the card up so
|
||||
16 px remain above it */}
|
||||
{!isSelfHosted && (
|
||||
<McpNotice
|
||||
className={cn("mb-5", compact ? "-mt-1" : "-mt-2")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Composer
|
||||
variant="hero"
|
||||
autoFocus={!compact}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { ArrowUpRight, Terminal, X } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useDictionary } from "@/hooks/use-dictionary"
|
||||
import { getAssetUrl } from "@/lib/base-path"
|
||||
import { STORAGE_KEYS } from "@/lib/storage"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const MCP_URL =
|
||||
"https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
|
||||
|
||||
/**
|
||||
* Whether the user closed the MCP video once. Starts as closed so the server
|
||||
* render and the first client render agree; the stored choice is read after
|
||||
* mount.
|
||||
*/
|
||||
function useVideoClosed() {
|
||||
const [closed, setClosed] = useState(true)
|
||||
useEffect(() => {
|
||||
setClosed(localStorage.getItem(STORAGE_KEYS.mcpVideoClosed) === "true")
|
||||
}, [])
|
||||
const close = () => {
|
||||
localStorage.setItem(STORAGE_KEYS.mcpVideoClosed, "true")
|
||||
setClosed(true)
|
||||
}
|
||||
return [closed, close] as const
|
||||
}
|
||||
|
||||
function PromoVideo({ className }: { className?: string }) {
|
||||
return (
|
||||
<video
|
||||
src={getAssetUrl("/mcp-promo.mp4")}
|
||||
poster={getAssetUrl("/mcp-promo-poster.jpg")}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
controls
|
||||
preload="metadata"
|
||||
className={cn("aspect-video w-full rounded-lg bg-black", className)}
|
||||
data-testid="mcp-video"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CloseButton({ onClick }: { onClick: () => void }) {
|
||||
const dict = useDictionary()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={dict.common.close}
|
||||
title={dict.common.close}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground"
|
||||
data-testid="mcp-video-close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** Icon, title and tagline of the MCP server, linking to its README */
|
||||
function NoticeLink({ className }: { className?: string }) {
|
||||
const dict = useDictionary()
|
||||
return (
|
||||
<a
|
||||
href={MCP_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn("group flex min-w-0 items-center gap-3", className)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-purple-500/20">
|
||||
<Terminal className="size-4 text-purple-500" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground transition-colors group-hover:text-purple-500">
|
||||
{dict.examples.mcpServer}
|
||||
</span>
|
||||
<span className="block text-xs leading-relaxed text-balance text-muted-foreground">
|
||||
{dict.examples.mcpTagline}
|
||||
</span>
|
||||
</span>
|
||||
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-purple-500" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const cardClass =
|
||||
"w-full rounded-xl border border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-blue-500/10"
|
||||
|
||||
/**
|
||||
* MCP server notice on the start screen. Until the user closes it, the card
|
||||
* also plays the promo video; after that it is the plain card for good.
|
||||
*/
|
||||
export function McpNotice({ className }: { className?: string }) {
|
||||
const [closed, close] = useVideoClosed()
|
||||
if (closed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
cardClass,
|
||||
"px-4 py-3 transition-colors hover:border-purple-500/40",
|
||||
className,
|
||||
)}
|
||||
data-testid="mcp-notice"
|
||||
>
|
||||
<NoticeLink />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(cardClass, "p-3", className)}
|
||||
data-testid="mcp-notice"
|
||||
>
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<NoticeLink className="flex-1" />
|
||||
<CloseButton onClick={close} />
|
||||
</div>
|
||||
<PromoVideo className="mt-3" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,7 +51,8 @@
|
||||
"creativeDescription": "Draw something fun and creative",
|
||||
"cachedNote": "Examples use saved answers: they appear instantly and don't call the AI.",
|
||||
"mcpServer": "MCP Server",
|
||||
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor"
|
||||
"mcpDescription": "Use in Claude Desktop, VS Code & Cursor",
|
||||
"mcpTagline": "Let Claude Code, Cursor or VS Code draw from your local files and code. You get the same canvas as here: live preview, outlined changes, undo."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
"creativeDescription": "楽しくてクリエイティブなものを描く",
|
||||
"cachedNote": "例は保存済みの回答を使うので、すぐに表示され、AI を呼び出しません。",
|
||||
"mcpServer": "MCP サーバー",
|
||||
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用"
|
||||
"mcpDescription": "Claude Desktop、VS Code、Cursor で使用",
|
||||
"mcpTagline": "Claude Code、Cursor、VS Code がローカルのファイルやコードを読んで図を描きます。キャンバスはここと同じで、リアルタイム表示、変更のハイライト、取り消しができます。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
"creativeDescription": "繪製有趣且富有創意的內容",
|
||||
"cachedNote": "範例使用保存好的答案,立即顯示,不呼叫 AI。",
|
||||
"mcpServer": "MCP 伺服器",
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
|
||||
"mcpTagline": "讓 Claude Code、Cursor 或 VS Code 直接讀取本機檔案和程式碼來繪圖。畫布和這裡一樣:即時預覽、標示改動、一鍵復原。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
|
||||
@@ -51,7 +51,8 @@
|
||||
"creativeDescription": "绘制有趣且富有创意的内容",
|
||||
"cachedNote": "示例使用保存好的答案,立即显示,不调用 AI。",
|
||||
"mcpServer": "MCP 服务器",
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用"
|
||||
"mcpDescription": "在 Claude Desktop、VS Code 和 Cursor 中使用",
|
||||
"mcpTagline": "让 Claude Code、Cursor 或 VS Code 直接读取本地文件和代码来画图。画布和这里一样:实时预览、标出改动、一键撤销。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
|
||||
@@ -29,4 +29,7 @@ export const STORAGE_KEYS = {
|
||||
|
||||
// Langfuse session id
|
||||
sessionId: "next-ai-draw-io-session-id",
|
||||
|
||||
// The start screen's MCP video was closed (it does not come back)
|
||||
mcpVideoClosed: "next-ai-draw-io-mcp-video-closed",
|
||||
} as const
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
@@ -170,6 +170,20 @@ test.describe("Workspace", () => {
|
||||
await expect(getChatInput(page)).toHaveValue("Draw a cat for me")
|
||||
})
|
||||
|
||||
test("the MCP card's video stays away once closed", async ({ page }) => {
|
||||
await openApp(page)
|
||||
const card = page.locator('[data-testid="mcp-notice"]')
|
||||
const video = page.locator('[data-testid="mcp-video"]')
|
||||
await expect(card.locator('[data-testid="mcp-video"]')).toBeVisible()
|
||||
await page.locator('[data-testid="mcp-video-close"]').click()
|
||||
await expect(video).toHaveCount(0)
|
||||
await expect(card).toBeVisible()
|
||||
await page.reload({ waitUntil: "networkidle" })
|
||||
await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible()
|
||||
await expect(card).toBeVisible()
|
||||
await expect(video).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("each AI change becomes a version card; the latest undoes and redoes it", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user