diff --git a/src/automerge/AutomergeToTLStore.ts b/src/automerge/AutomergeToTLStore.ts index 7081abe..9db1da2 100644 --- a/src/automerge/AutomergeToTLStore.ts +++ b/src/automerge/AutomergeToTLStore.ts @@ -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 } diff --git a/src/components/StandardizedToolWrapper.tsx b/src/components/StandardizedToolWrapper.tsx index 18fa631..a6d1ab1 100644 --- a/src/components/StandardizedToolWrapper.tsx +++ b/src/components/StandardizedToolWrapper.tsx @@ -156,8 +156,8 @@ export const StandardizedToolWrapper: React.FC = ( } 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 = ( 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 = ( } 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 = ( } 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 = ( } const addTagButtonStyle: React.CSSProperties = { - backgroundColor: '#007acc', + backgroundColor: '#9ca3af', color: 'white', border: 'none', borderRadius: '12px', @@ -310,18 +310,30 @@ export const StandardizedToolWrapper: React.FC = ( 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) => { diff --git a/src/shapes/ImageGenShapeUtil.tsx b/src/shapes/ImageGenShapeUtil.tsx index e9fdfd7..b752c6e 100644 --- a/src/shapes/ImageGenShapeUtil.tsx +++ b/src/shapes/ImageGenShapeUtil.tsx @@ -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 { 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 { imageUrl: null, isLoading: false, error: null, + tags: ['image', 'ai-generated'], + pinnedToView: false, } } @@ -302,9 +311,19 @@ export class ImageGenShape extends BaseBoxShapeUtil { 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({ + 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 { } } + const [isMinimized, setIsMinimized] = useState(false) + + const handleClose = () => { + editor.deleteShape(shape.id) + } + + const handleMinimize = () => { + setIsMinimized(!isMinimized) + } + + const handleTagsChange = (newTags: string[]) => { + editor.updateShape({ + id: shape.id, + type: "ImageGen", + props: { tags: newTags }, + }) + } + return ( - setIsHovering(true)} - onPointerLeave={() => setIsHovering(false)} - > - {/* Error Display */} - {shape.props.error && ( -
- ⚠️ - {shape.props.error} - + { + editor.updateShape({ + 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} + /> + +
+ + {/* Error Display - at bottom */} + {shape.props.error && ( +
+ ⚠️ + {shape.props.error} + +
+ )} - )} - {/* Image Display */} - {shape.props.imageUrl && !shape.props.isLoading && ( -
- {shape.props.prompt { - console.error("❌ ImageGen: Failed to load image:", shape.props.imageUrl) - editor.updateShape({ - id: shape.id, - type: "ImageGen", - props: { - error: "Failed to load generated image", - imageUrl: null - }, - }) - }} - /> -
- )} - - {/* Loading State */} - {shape.props.isLoading && ( -
-
- - Generating image... - -
- )} - - {/* Empty State */} - {!shape.props.imageUrl && !shape.props.isLoading && ( -
- Generated image will appear here -
- )} - - {/* Input Section */} -
- { - editor.updateShape({ - 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} - /> - -
- - {/* Add CSS for spinner animation */} - + {/* Add CSS for spinner animation */} + + ) } diff --git a/src/shapes/MultmuxShapeUtil.tsx b/src/shapes/MultmuxShapeUtil.tsx index c243fd0..3b7917e 100644 --- a/src/shapes/MultmuxShapeUtil.tsx +++ b/src/shapes/MultmuxShapeUtil.tsx @@ -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 { // 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({ + id: shape.id, + type: 'Multmux', + props: { + ...shape.props, + serverUrl: 'http://localhost:3002', + }, + }) + } + }, [shape.props.serverUrl]) + const handleClose = () => { if (ws) { ws.close() diff --git a/src/shapes/ObsNoteShapeUtil.tsx b/src/shapes/ObsNoteShapeUtil.tsx index 5b7a533..a46e2e0 100644 --- a/src/shapes/ObsNoteShapeUtil.tsx +++ b/src/shapes/ObsNoteShapeUtil.tsx @@ -1047,7 +1047,8 @@ export class ObsNoteShape extends BaseBoxShapeUtil { 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 { * 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 { 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', diff --git a/src/shapes/VideoGenShapeUtil.tsx b/src/shapes/VideoGenShapeUtil.tsx index fd36e85..ff72e73 100644 --- a/src/shapes/VideoGenShapeUtil.tsx +++ b/src/shapes/VideoGenShapeUtil.tsx @@ -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 { 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 { } 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(shape.props.error) const [videoUrl, setVideoUrl] = useState(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({ + 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 { 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 { 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 { 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 { } const handleClose = () => { - this.editor.deleteShape(shape.id) + editor.deleteShape(shape.id) } const handleMinimize = () => { @@ -232,7 +248,7 @@ export class VideoGenShape extends BaseBoxShapeUtil { } 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 { 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 ? ( @@ -320,7 +338,7 @@ export class VideoGenShape extends BaseBoxShapeUtil { 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 { onClick={() => { setVideoUrl(null) setPrompt("") - this.editor.updateShape({ + editor.updateShape({ id: shape.id, type: shape.type, props: { ...shape.props, videoUrl: null, prompt: "" } diff --git a/src/tools/MultmuxTool.ts b/src/tools/MultmuxTool.ts index d6c89d1..4ec71cc 100644 --- a/src/tools/MultmuxTool.ts +++ b/src/tools/MultmuxTool.ts @@ -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'], } diff --git a/worker/schema.sql b/worker/schema.sql new file mode 100644 index 0000000..d61cc80 --- /dev/null +++ b/worker/schema.sql @@ -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); diff --git a/wrangler.toml b/wrangler.toml index 9532a26..be7dacc 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -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