Merge branch 'Pouzor:main' into main
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Check } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
@@ -14,17 +14,21 @@ interface ThemeCardProps {
|
||||
themeId: ThemeId
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
onKeyDown?: (event: KeyboardEvent<HTMLButtonElement>) => void
|
||||
buttonRef?: (element: HTMLButtonElement | null) => void
|
||||
}
|
||||
|
||||
function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
|
||||
function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef }: ThemeCardProps) {
|
||||
const preset = THEMES[themeId]
|
||||
const c = preset.colors
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
|
||||
onKeyDown={onKeyDown}
|
||||
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full h-full flex flex-col"
|
||||
style={{
|
||||
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
||||
background: c.canvasBackground,
|
||||
@@ -70,13 +74,13 @@ function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
|
||||
|
||||
{/* Label */}
|
||||
<div
|
||||
className="text-xs font-semibold leading-tight"
|
||||
className="text-sm font-semibold leading-tight wrap-break-word"
|
||||
style={{ color: c.nodeLabelColor }}
|
||||
>
|
||||
{preset.label}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] leading-snug mt-0.5 line-clamp-2"
|
||||
className="text-xs leading-snug mt-1 line-clamp-3 whitespace-normal wrap-break-word overflow-hidden min-h-12"
|
||||
style={{ color: c.nodeSubtextColor }}
|
||||
>
|
||||
{preset.description}
|
||||
@@ -93,6 +97,7 @@ interface ThemeModalProps {
|
||||
export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
||||
const { activeTheme, setTheme } = useThemeStore()
|
||||
const { markUnsaved } = useCanvasStore()
|
||||
const cardRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||
|
||||
// Capture the theme that was active when the modal opened
|
||||
const [originalTheme] = useState<ThemeId>(activeTheme)
|
||||
@@ -104,6 +109,30 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
||||
setTheme(id)
|
||||
}
|
||||
|
||||
const handleCardKeyDown = (index: number) => (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleApply()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||
|
||||
event.preventDefault()
|
||||
const direction = event.key === 'ArrowRight' ? 1 : -1
|
||||
const nextIndex = (index + direction + THEME_ORDER.length) % THEME_ORDER.length
|
||||
const nextTheme = THEME_ORDER[nextIndex]
|
||||
|
||||
setSelected(nextTheme)
|
||||
setTheme(nextTheme)
|
||||
|
||||
const nextCard = cardRefs.current[nextIndex]
|
||||
if (!nextCard) return
|
||||
|
||||
nextCard.focus({ preventScroll: true })
|
||||
nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
setTheme(selected)
|
||||
markUnsaved()
|
||||
@@ -121,19 +150,24 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] w-[90vw] max-w-4xl">
|
||||
<DialogContent className="bg-[#161b22] border-[#30363d] w-fit max-w-[calc(100%-2rem)] sm:max-w-[50vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-5 gap-3 py-1">
|
||||
{THEME_ORDER.map((id) => (
|
||||
<ThemeCard
|
||||
key={id}
|
||||
themeId={id}
|
||||
selected={selected === id}
|
||||
onClick={() => handleSelect(id)}
|
||||
/>
|
||||
<div className="flex items-stretch flex-nowrap gap-3 py-1 overflow-x-auto overflow-y-hidden pb-2 pr-1">
|
||||
{THEME_ORDER.map((id, index) => (
|
||||
<div key={id} className="shrink-0 w-30 md:w-24 h-full">
|
||||
<ThemeCard
|
||||
themeId={id}
|
||||
selected={selected === id}
|
||||
onClick={() => handleSelect(id)}
|
||||
onKeyDown={handleCardKeyDown(index)}
|
||||
buttonRef={(element) => {
|
||||
cardRefs.current[index] = element
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
+13
-7
@@ -1,7 +1,8 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import FastAPI
|
||||
from mcp.server import Server
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from starlette.routing import Mount
|
||||
|
||||
from .auth import ApiKeyMiddleware
|
||||
from .backend_client import backend
|
||||
@@ -28,15 +29,20 @@ async def lifespan(app: FastAPI):
|
||||
await backend.stop()
|
||||
|
||||
|
||||
app = FastAPI(title="Homelable MCP", lifespan=lifespan)
|
||||
# Mount the session manager as an ASGI sub-app instead of wrapping it in a
|
||||
# FastAPI @app.api_route handler. Wrapping it in a route handler causes
|
||||
# FastAPI to send http.response.start after the session manager has already
|
||||
# started the response, raising `RuntimeError: Unexpected ASGI message
|
||||
# 'http.response.start' sent, after response already completed` on every
|
||||
# POST /mcp — which makes the server unreachable from any MCP client.
|
||||
app = FastAPI(
|
||||
title="Homelable MCP",
|
||||
lifespan=lifespan,
|
||||
routes=[Mount("/mcp", app=session_manager.handle_request)],
|
||||
)
|
||||
app.add_middleware(ApiKeyMiddleware)
|
||||
|
||||
|
||||
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
|
||||
async def mcp_endpoint(request: Request):
|
||||
await session_manager.handle_request(request.scope, request.receive, request._send)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
Reference in New Issue
Block a user