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:
Jeff Emmett 2025-11-30 21:14:51 -08:00
parent 5a22786195
commit c5784cfd5a
9 changed files with 457 additions and 253 deletions

View File

@ -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
}

View File

@ -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) => {

View File

@ -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>
)
}

View File

@ -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()

View File

@ -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',

View File

@ -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: "" }

View File

@ -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'],
}

47
worker/schema.sql Normal file
View File

@ -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);

View File

@ -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