feat: standardize tool shapes with pin functionality and UI improvements
- Add pin functionality to ImageGen and VideoGen shapes - Refactor ImageGen to use StandardizedToolWrapper with tags support - Update StandardizedToolWrapper: grey tags, fix button overlap, improve header drag - Fix index validation in AutomergeToTLStore for old format indices - Update wrangler.toml with latest compatibility date and RunPod endpoint docs - Refactor VideoGen to use captured editor reference for consistency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
090646f893
commit
c396bbca85
|
|
@ -714,7 +714,11 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
const sessionId = (typeof sanitized.props.sessionId === 'string') ? sanitized.props.sessionId : ''
|
||||
const sessionName = (typeof sanitized.props.sessionName === 'string') ? sanitized.props.sessionName : ''
|
||||
const token = (typeof sanitized.props.token === 'string') ? sanitized.props.token : ''
|
||||
const serverUrl = (typeof sanitized.props.serverUrl === 'string') ? sanitized.props.serverUrl : 'http://localhost:3000'
|
||||
// Fix old port (3000 -> 3002) during sanitization
|
||||
let serverUrl = (typeof sanitized.props.serverUrl === 'string') ? sanitized.props.serverUrl : 'http://localhost:3002'
|
||||
if (serverUrl === 'http://localhost:3000') {
|
||||
serverUrl = 'http://localhost:3002'
|
||||
}
|
||||
const pinnedToView = (sanitized.props.pinnedToView === true) ? true : false
|
||||
// Filter out any undefined or non-string elements from tags array
|
||||
let tags: string[] = ['terminal', 'multmux']
|
||||
|
|
@ -749,7 +753,7 @@ export function sanitizeRecord(record: any): TLRecord {
|
|||
case 'sessionId': (cleanProps as any).sessionId = ''; break
|
||||
case 'sessionName': (cleanProps as any).sessionName = ''; break
|
||||
case 'token': (cleanProps as any).token = ''; break
|
||||
case 'serverUrl': (cleanProps as any).serverUrl = 'http://localhost:3000'; break
|
||||
case 'serverUrl': (cleanProps as any).serverUrl = 'http://localhost:3002'; break
|
||||
case 'pinnedToView': (cleanProps as any).pinnedToView = false; break
|
||||
case 'tags': (cleanProps as any).tags = ['terminal', 'multmux']; break
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,8 +156,8 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
}
|
||||
|
||||
const buttonBaseStyle: React.CSSProperties = {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
|
|
@ -170,8 +170,8 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
pointerEvents: 'auto',
|
||||
flexShrink: 0,
|
||||
touchAction: 'manipulation', // Prevent double-tap zoom, improve touch responsiveness
|
||||
padding: '8px', // Increase touch target size without changing visual size
|
||||
margin: '-8px', // Negative margin to maintain visual spacing
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
}
|
||||
|
||||
const minimizeButtonStyle: React.CSSProperties = {
|
||||
|
|
@ -222,7 +222,7 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
}
|
||||
|
||||
const tagStyle: React.CSSProperties = {
|
||||
backgroundColor: '#007acc',
|
||||
backgroundColor: '#6b7280',
|
||||
color: 'white',
|
||||
padding: '4px 8px', // Increased padding for better touch target
|
||||
borderRadius: '12px',
|
||||
|
|
@ -237,7 +237,7 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
}
|
||||
|
||||
const tagInputStyle: React.CSSProperties = {
|
||||
border: '1px solid #007acc',
|
||||
border: '1px solid #9ca3af',
|
||||
borderRadius: '12px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '10px',
|
||||
|
|
@ -247,7 +247,7 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
}
|
||||
|
||||
const addTagButtonStyle: React.CSSProperties = {
|
||||
backgroundColor: '#007acc',
|
||||
backgroundColor: '#9ca3af',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
|
|
@ -310,18 +310,30 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
|||
const handleHeaderPointerDown = (e: React.PointerEvent) => {
|
||||
// Check if this is an interactive element (button)
|
||||
const target = e.target as HTMLElement
|
||||
const isInteractive =
|
||||
target.tagName === 'BUTTON' ||
|
||||
const isInteractive =
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.closest('button') ||
|
||||
target.closest('[role="button"]')
|
||||
|
||||
|
||||
if (isInteractive) {
|
||||
// Buttons handle their own behavior and stop propagation
|
||||
return
|
||||
}
|
||||
|
||||
// Don't stop the event - let tldraw handle it naturally
|
||||
// The hand tool override will detect shapes and handle dragging
|
||||
|
||||
// CRITICAL: Switch to select tool and select this shape when dragging header
|
||||
// This ensures dragging works regardless of which tool is currently active
|
||||
if (editor && shapeId) {
|
||||
const currentTool = editor.getCurrentToolId()
|
||||
if (currentTool !== 'select') {
|
||||
editor.setCurrentTool('select')
|
||||
}
|
||||
// Select this shape if not already selected
|
||||
if (!isSelected) {
|
||||
editor.setSelectedShapes([shapeId])
|
||||
}
|
||||
}
|
||||
|
||||
// Don't stop the event - let tldraw handle the drag naturally
|
||||
}
|
||||
|
||||
const handleButtonClick = (e: React.MouseEvent, action: () => void) => {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import {
|
|||
import React, { useState } from "react"
|
||||
import { getRunPodConfig } from "@/lib/clientConfig"
|
||||
import { aiOrchestrator, isAIOrchestratorAvailable } from "@/lib/aiOrchestrator"
|
||||
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
|
||||
import { usePinnedToView } from "@/hooks/usePinnedToView"
|
||||
|
||||
// Feature flag: Set to false when AI Orchestrator or RunPod API is ready for production
|
||||
const USE_MOCK_API = false
|
||||
|
|
@ -44,6 +46,8 @@ type IImageGen = TLBaseShape<
|
|||
isLoading: boolean
|
||||
error: string | null
|
||||
endpointId?: string // Optional custom endpoint ID
|
||||
tags: string[]
|
||||
pinnedToView: boolean
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -274,6 +278,9 @@ async function pollRunPodJob(
|
|||
export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
||||
static override type = "ImageGen" as const
|
||||
|
||||
// Image generation theme color: Blue
|
||||
static readonly PRIMARY_COLOR = "#007AFF"
|
||||
|
||||
MIN_WIDTH = 300 as const
|
||||
MIN_HEIGHT = 300 as const
|
||||
DEFAULT_WIDTH = 400 as const
|
||||
|
|
@ -287,6 +294,8 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
imageUrl: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
tags: ['image', 'ai-generated'],
|
||||
pinnedToView: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -302,9 +311,19 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
component(shape: IImageGen) {
|
||||
// Capture editor reference to avoid stale 'this' during drag operations
|
||||
const editor = this.editor
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const isSelected = editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
// Pin to view functionality
|
||||
usePinnedToView(editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
const handlePinToggle = () => {
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: { pinnedToView: !shape.props.pinnedToView },
|
||||
})
|
||||
}
|
||||
|
||||
const generateImage = async (prompt: string) => {
|
||||
console.log("🎨 ImageGen: Generating image with prompt:", prompt)
|
||||
|
||||
|
|
@ -503,237 +522,293 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
|||
}
|
||||
}
|
||||
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
|
||||
const handleClose = () => {
|
||||
editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setIsMinimized(!isMinimized)
|
||||
}
|
||||
|
||||
const handleTagsChange = (newTags: string[]) => {
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: { tags: newTags },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<HTMLContainer
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
border: "1px solid lightgrey",
|
||||
padding: 8,
|
||||
height: shape.props.h,
|
||||
width: shape.props.w,
|
||||
pointerEvents: isSelected || isHovering ? "all" : "none",
|
||||
backgroundColor: "#ffffff",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
onPointerEnter={() => setIsHovering(true)}
|
||||
onPointerLeave={() => setIsHovering(false)}
|
||||
>
|
||||
{/* Error Display */}
|
||||
{shape.props.error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
backgroundColor: "#fee",
|
||||
border: "1px solid #fcc",
|
||||
borderRadius: "8px",
|
||||
color: "#c33",
|
||||
fontSize: "13px",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "8px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "18px", flexShrink: 0 }}>⚠️</span>
|
||||
<span style={{ flex: 1, lineHeight: "1.5" }}>{shape.props.error}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: { error: null },
|
||||
})
|
||||
}}
|
||||
<HTMLContainer id={shape.id}>
|
||||
<StandardizedToolWrapper
|
||||
title="🎨 Image Generator"
|
||||
primaryColor={ImageGenShape.PRIMARY_COLOR}
|
||||
isSelected={isSelected}
|
||||
width={shape.props.w}
|
||||
height={shape.props.h}
|
||||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
editor={editor}
|
||||
shapeId={shape.id}
|
||||
tags={shape.props.tags || []}
|
||||
onTagsChange={handleTagsChange}
|
||||
tagsEditable={true}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
headerContent={
|
||||
shape.props.isLoading ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
🎨 Image Generator
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: '11px',
|
||||
color: ImageGenShape.PRIMARY_COLOR,
|
||||
animation: 'pulse 1.5s ease-in-out infinite'
|
||||
}}>
|
||||
Generating...
|
||||
</span>
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: '12px',
|
||||
gap: '12px',
|
||||
overflow: 'auto',
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
{/* Image Display */}
|
||||
{shape.props.imageUrl && !shape.props.isLoading && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: "6px",
|
||||
overflow: "hidden",
|
||||
minHeight: 0,
|
||||
border: '1px solid #e0e0e0',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={shape.props.imageUrl}
|
||||
alt={shape.props.prompt || "Generated image"}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
onError={(_e) => {
|
||||
console.error("❌ ImageGen: Failed to load image:", shape.props.imageUrl)
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: {
|
||||
error: "Failed to load generated image",
|
||||
imageUrl: null
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{shape.props.isLoading && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: "6px",
|
||||
gap: 12,
|
||||
border: '1px solid #e0e0e0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: "4px solid #f3f3f3",
|
||||
borderTop: `4px solid ${ImageGenShape.PRIMARY_COLOR}`,
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: "#666", fontSize: "14px" }}>
|
||||
Generating image...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!shape.props.imageUrl && !shape.props.isLoading && !shape.props.error && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#fff",
|
||||
borderRadius: "6px",
|
||||
color: "#999",
|
||||
fontSize: "14px",
|
||||
border: '1px solid #e0e0e0',
|
||||
}}
|
||||
>
|
||||
Generated image will appear here
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Section */}
|
||||
<div
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
backgroundColor: "#fcc",
|
||||
border: "1px solid #c99",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "11px",
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<input
|
||||
style={{
|
||||
flex: 1,
|
||||
height: "36px",
|
||||
backgroundColor: "#fff",
|
||||
border: "1px solid #ddd",
|
||||
borderRadius: "6px",
|
||||
fontSize: 13,
|
||||
padding: "0 10px",
|
||||
}}
|
||||
type="text"
|
||||
placeholder="Enter image prompt..."
|
||||
value={shape.props.prompt}
|
||||
onChange={(e) => {
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: { prompt: e.target.value },
|
||||
})
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (shape.props.prompt.trim() && !shape.props.isLoading) {
|
||||
handleGenerate()
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
disabled={shape.props.isLoading}
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
height: "36px",
|
||||
padding: "0 16px",
|
||||
pointerEvents: "all",
|
||||
cursor: shape.props.prompt.trim() && !shape.props.isLoading ? "pointer" : "not-allowed",
|
||||
backgroundColor: shape.props.prompt.trim() && !shape.props.isLoading ? ImageGenShape.PRIMARY_COLOR : "#ccc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
fontWeight: "500",
|
||||
fontSize: "13px",
|
||||
opacity: shape.props.prompt.trim() && !shape.props.isLoading ? 1 : 0.6,
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (shape.props.prompt.trim() && !shape.props.isLoading) {
|
||||
handleGenerate()
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (shape.props.prompt.trim() && !shape.props.isLoading) {
|
||||
handleGenerate()
|
||||
}
|
||||
}}
|
||||
disabled={shape.props.isLoading || !shape.props.prompt.trim()}
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Display - at bottom */}
|
||||
{shape.props.error && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
backgroundColor: "#fee",
|
||||
border: "1px solid #fcc",
|
||||
borderRadius: "6px",
|
||||
color: "#c33",
|
||||
fontSize: "12px",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: "8px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
maxHeight: "80px",
|
||||
overflowY: "auto",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "14px", flexShrink: 0 }}>⚠️</span>
|
||||
<span style={{ flex: 1, lineHeight: "1.4" }}>{shape.props.error}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: { error: null },
|
||||
})
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
padding: "2px 6px",
|
||||
backgroundColor: "#fcc",
|
||||
border: "1px solid #c99",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
fontSize: "10px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Display */}
|
||||
{shape.props.imageUrl && !shape.props.isLoading && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderRadius: "4px",
|
||||
overflow: "hidden",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={shape.props.imageUrl}
|
||||
alt={shape.props.prompt || "Generated image"}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
onError={(_e) => {
|
||||
console.error("❌ ImageGen: Failed to load image:", shape.props.imageUrl)
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: {
|
||||
error: "Failed to load generated image",
|
||||
imageUrl: null
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{shape.props.isLoading && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderRadius: "4px",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: "4px solid #f3f3f3",
|
||||
borderTop: "4px solid #007AFF",
|
||||
borderRadius: "50%",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: "#666", fontSize: "14px" }}>
|
||||
Generating image...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!shape.props.imageUrl && !shape.props.isLoading && (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f5f5f5",
|
||||
borderRadius: "4px",
|
||||
color: "#999",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
Generated image will appear here
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Section */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
pointerEvents: isSelected || isHovering ? "all" : "none",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
style={{
|
||||
flex: 1,
|
||||
height: "36px",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.05)",
|
||||
border: "1px solid rgba(0, 0, 0, 0.1)",
|
||||
borderRadius: "4px",
|
||||
fontSize: 14,
|
||||
padding: "0 8px",
|
||||
}}
|
||||
type="text"
|
||||
placeholder="Enter image prompt..."
|
||||
value={shape.props.prompt}
|
||||
onChange={(e) => {
|
||||
editor.updateShape<IImageGen>({
|
||||
id: shape.id,
|
||||
type: "ImageGen",
|
||||
props: { prompt: e.target.value },
|
||||
})
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (shape.props.prompt.trim() && !shape.props.isLoading) {
|
||||
handleGenerate()
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
disabled={shape.props.isLoading}
|
||||
/>
|
||||
<button
|
||||
style={{
|
||||
height: "36px",
|
||||
padding: "0 16px",
|
||||
pointerEvents: "all",
|
||||
cursor: shape.props.prompt.trim() && !shape.props.isLoading ? "pointer" : "not-allowed",
|
||||
backgroundColor: shape.props.prompt.trim() && !shape.props.isLoading ? "#007AFF" : "#ccc",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
fontWeight: "500",
|
||||
fontSize: "14px",
|
||||
opacity: shape.props.prompt.trim() && !shape.props.isLoading ? 1 : 0.6,
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (shape.props.prompt.trim() && !shape.props.isLoading) {
|
||||
handleGenerate()
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (shape.props.prompt.trim() && !shape.props.isLoading) {
|
||||
handleGenerate()
|
||||
}
|
||||
}}
|
||||
disabled={shape.props.isLoading || !shape.props.prompt.trim()}
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add CSS for spinner animation */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
{/* Add CSS for spinner animation */}
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
`}</style>
|
||||
</StandardizedToolWrapper>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ function httpToWs(httpUrl: string): string {
|
|||
const versions = createShapePropsMigrationIds('Multmux', {
|
||||
AddMissingProps: 1,
|
||||
RemoveWsUrl: 2,
|
||||
UpdateServerPort: 3,
|
||||
})
|
||||
|
||||
// Migrations to handle shapes with missing/undefined props
|
||||
|
|
@ -82,6 +83,21 @@ export const multmuxShapeMigrations = createShapePropsMigrationSequence({
|
|||
wsUrl: httpToWs(props.serverUrl || 'http://localhost:3002'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: versions.UpdateServerPort,
|
||||
up: (props: any) => {
|
||||
// Update old port 3000 to new port 3002
|
||||
let serverUrl = props.serverUrl ?? 'http://localhost:3002'
|
||||
if (serverUrl === 'http://localhost:3000') {
|
||||
serverUrl = 'http://localhost:3002'
|
||||
}
|
||||
return {
|
||||
...props,
|
||||
serverUrl,
|
||||
}
|
||||
},
|
||||
down: (props: any) => props,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
|
@ -142,6 +158,21 @@ export class MultmuxShape extends BaseBoxShapeUtil<IMultmuxShape> {
|
|||
// Use the pinning hook
|
||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
// Runtime fix: correct old serverUrl port (3000 -> 3002)
|
||||
// This handles shapes that may not have been migrated yet
|
||||
useEffect(() => {
|
||||
if (shape.props.serverUrl === 'http://localhost:3000') {
|
||||
this.editor.updateShape<IMultmuxShape>({
|
||||
id: shape.id,
|
||||
type: 'Multmux',
|
||||
props: {
|
||||
...shape.props,
|
||||
serverUrl: 'http://localhost:3002',
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [shape.props.serverUrl])
|
||||
|
||||
const handleClose = () => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
|
|
|
|||
|
|
@ -1047,7 +1047,8 @@ export class ObsNoteShape extends BaseBoxShapeUtil<IObsNoteShape> {
|
|||
noteId: typeof props.noteId === 'string' ? props.noteId : '',
|
||||
title: typeof props.title === 'string' ? props.title : 'Untitled ObsNote',
|
||||
content: typeof props.content === 'string' ? props.content : '',
|
||||
tags,
|
||||
// Use provided tags, or default to 'obsidian note' and 'markdown' if empty
|
||||
tags: tags.length > 0 ? tags : ['obsidian note', 'markdown'],
|
||||
showPreview: typeof props.showPreview === 'boolean' ? props.showPreview : true,
|
||||
backgroundColor: typeof props.backgroundColor === 'string' ? props.backgroundColor : '#ffffff',
|
||||
textColor: typeof props.textColor === 'string' ? props.textColor : '#000000',
|
||||
|
|
@ -1145,6 +1146,12 @@ export class ObsNoteShape extends BaseBoxShapeUtil<IObsNoteShape> {
|
|||
* Create an obs_note shape from an ObsidianObsNote
|
||||
*/
|
||||
static createFromObsidianObsNote(obs_note: ObsidianObsNote, x: number = 0, y: number = 0, id?: TLShapeId, vaultPath?: string, vaultName?: string): IObsNoteShape {
|
||||
// Use tags from Obsidian if they exist, otherwise use default tags
|
||||
// Obsidian tags may include '#' prefix, we preserve them as-is
|
||||
const obsidianTags = obs_note.tags && obs_note.tags.length > 0
|
||||
? obs_note.tags
|
||||
: ['obsidian note', 'markdown']
|
||||
|
||||
// Use sanitizeProps to ensure all values are JSON serializable
|
||||
const props = ObsNoteShape.sanitizeProps({
|
||||
w: 300,
|
||||
|
|
@ -1157,7 +1164,7 @@ export class ObsNoteShape extends BaseBoxShapeUtil<IObsNoteShape> {
|
|||
noteId: obs_note.id || '',
|
||||
title: obs_note.title || 'Untitled',
|
||||
content: obs_note.content || '',
|
||||
tags: obs_note.tags || [],
|
||||
tags: obsidianTags,
|
||||
showPreview: true,
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#000000',
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
import React, { useState } from "react"
|
||||
import { getRunPodVideoConfig } from "@/lib/clientConfig"
|
||||
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
|
||||
import { usePinnedToView } from "@/hooks/usePinnedToView"
|
||||
|
||||
// Type for RunPod job response
|
||||
interface RunPodJobResponse {
|
||||
|
|
@ -33,6 +34,7 @@ type IVideoGen = TLBaseShape<
|
|||
duration: number // seconds
|
||||
model: string
|
||||
tags: string[]
|
||||
pinnedToView: boolean
|
||||
}
|
||||
>
|
||||
|
||||
|
|
@ -52,7 +54,8 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
|||
error: null,
|
||||
duration: 3,
|
||||
model: "wan2.1-i2v",
|
||||
tags: ['video', 'ai-generated']
|
||||
tags: ['video', 'ai-generated'],
|
||||
pinnedToView: false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,12 +69,25 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
|||
}
|
||||
|
||||
component(shape: IVideoGen) {
|
||||
// Capture editor reference to avoid stale 'this' during drag operations
|
||||
const editor = this.editor
|
||||
const [prompt, setPrompt] = useState(shape.props.prompt)
|
||||
const [isGenerating, setIsGenerating] = useState(shape.props.isLoading)
|
||||
const [error, setError] = useState<string | null>(shape.props.error)
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(shape.props.videoUrl)
|
||||
const [isMinimized, setIsMinimized] = useState(false)
|
||||
const isSelected = this.editor.getSelectedShapeIds().includes(shape.id)
|
||||
const isSelected = editor.getSelectedShapeIds().includes(shape.id)
|
||||
|
||||
// Pin to view functionality
|
||||
usePinnedToView(editor, shape.id, shape.props.pinnedToView)
|
||||
|
||||
const handlePinToggle = () => {
|
||||
editor.updateShape<IVideoGen>({
|
||||
id: shape.id,
|
||||
type: "VideoGen",
|
||||
props: { pinnedToView: !shape.props.pinnedToView },
|
||||
})
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) {
|
||||
|
|
@ -91,7 +107,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
|||
setError(null)
|
||||
|
||||
// Update shape to show loading state
|
||||
this.editor.updateShape({
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: { ...shape.props, isLoading: true, error: null }
|
||||
|
|
@ -186,7 +202,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
|||
setVideoUrl(url)
|
||||
setIsGenerating(false)
|
||||
|
||||
this.editor.updateShape({
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: {
|
||||
|
|
@ -215,7 +231,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
|||
setError(errorMessage)
|
||||
setIsGenerating(false)
|
||||
|
||||
this.editor.updateShape({
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: { ...shape.props, isLoading: false, error: errorMessage }
|
||||
|
|
@ -224,7 +240,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
|||
}
|
||||
|
||||
const handleClose = () => {
|
||||
this.editor.deleteShape(shape.id)
|
||||
editor.deleteShape(shape.id)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
|
|
@ -232,7 +248,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
|||
}
|
||||
|
||||
const handleTagsChange = (newTags: string[]) => {
|
||||
this.editor.updateShape({
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: { ...shape.props, tags: newTags }
|
||||
|
|
@ -250,11 +266,13 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
|||
onClose={handleClose}
|
||||
onMinimize={handleMinimize}
|
||||
isMinimized={isMinimized}
|
||||
editor={this.editor}
|
||||
editor={editor}
|
||||
shapeId={shape.id}
|
||||
tags={shape.props.tags}
|
||||
onTagsChange={handleTagsChange}
|
||||
tagsEditable={true}
|
||||
isPinnedToView={shape.props.pinnedToView}
|
||||
onPinToggle={handlePinToggle}
|
||||
headerContent={
|
||||
isGenerating ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
|
|
@ -320,7 +338,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
|||
max="10"
|
||||
value={shape.props.duration}
|
||||
onChange={(e) => {
|
||||
this.editor.updateShape({
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: { ...shape.props, duration: parseInt(e.target.value) || 3 }
|
||||
|
|
@ -429,7 +447,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
|||
onClick={() => {
|
||||
setVideoUrl(null)
|
||||
setPrompt("")
|
||||
this.editor.updateShape({
|
||||
editor.updateShape({
|
||||
id: shape.id,
|
||||
type: shape.type,
|
||||
props: { ...shape.props, videoUrl: null, prompt: "" }
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export class MultmuxIdle extends StateNode {
|
|||
sessionId: '',
|
||||
sessionName: '',
|
||||
token: '',
|
||||
serverUrl: 'http://localhost:3000',
|
||||
serverUrl: 'http://localhost:3002',
|
||||
pinnedToView: false,
|
||||
tags: ['terminal', 'multmux'],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
-- CryptID Authentication Schema
|
||||
-- Cloudflare D1 Database
|
||||
|
||||
-- User accounts (one per email, linked to CryptID username)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
cryptid_username TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Device keys (multiple devices per user account)
|
||||
CREATE TABLE IF NOT EXISTS device_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL UNIQUE,
|
||||
device_name TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
last_used TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Verification tokens for email verification and device linking
|
||||
CREATE TABLE IF NOT EXISTS verification_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
token_type TEXT NOT NULL CHECK (token_type IN ('email_verify', 'device_link')),
|
||||
public_key TEXT,
|
||||
device_name TEXT,
|
||||
user_agent TEXT,
|
||||
expires_at TEXT NOT NULL,
|
||||
used INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_cryptid ON users(cryptid_username);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_keys_user ON device_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_device_keys_pubkey ON device_keys(public_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_token ON verification_tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_email ON verification_tokens(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_expires ON verification_tokens(expires_at);
|
||||
|
|
@ -58,6 +58,11 @@ bucket_name = 'jeffemmett-canvas'
|
|||
binding = 'BOARD_BACKUPS_BUCKET'
|
||||
bucket_name = 'board-backups'
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "CRYPTID_DB"
|
||||
database_name = "cryptid-auth"
|
||||
database_id = "placeholder-will-be-created"
|
||||
|
||||
[observability]
|
||||
enabled = true
|
||||
head_sampling_rate = 1
|
||||
|
|
@ -91,6 +96,11 @@ bucket_name = 'jeffemmett-canvas-preview'
|
|||
binding = 'BOARD_BACKUPS_BUCKET'
|
||||
bucket_name = 'board-backups-preview'
|
||||
|
||||
[[env.dev.d1_databases]]
|
||||
binding = "CRYPTID_DB"
|
||||
database_name = "cryptid-auth-dev"
|
||||
database_id = "placeholder-will-be-created-dev"
|
||||
|
||||
[env.dev.triggers]
|
||||
crons = ["0 0 * * *"] # Run at midnight UTC every day
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue