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 sessionId = (typeof sanitized.props.sessionId === 'string') ? sanitized.props.sessionId : ''
|
||||||
const sessionName = (typeof sanitized.props.sessionName === 'string') ? sanitized.props.sessionName : ''
|
const sessionName = (typeof sanitized.props.sessionName === 'string') ? sanitized.props.sessionName : ''
|
||||||
const token = (typeof sanitized.props.token === 'string') ? sanitized.props.token : ''
|
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
|
const pinnedToView = (sanitized.props.pinnedToView === true) ? true : false
|
||||||
// Filter out any undefined or non-string elements from tags array
|
// Filter out any undefined or non-string elements from tags array
|
||||||
let tags: string[] = ['terminal', 'multmux']
|
let tags: string[] = ['terminal', 'multmux']
|
||||||
|
|
@ -749,7 +753,7 @@ export function sanitizeRecord(record: any): TLRecord {
|
||||||
case 'sessionId': (cleanProps as any).sessionId = ''; break
|
case 'sessionId': (cleanProps as any).sessionId = ''; break
|
||||||
case 'sessionName': (cleanProps as any).sessionName = ''; break
|
case 'sessionName': (cleanProps as any).sessionName = ''; break
|
||||||
case 'token': (cleanProps as any).token = ''; 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 'pinnedToView': (cleanProps as any).pinnedToView = false; break
|
||||||
case 'tags': (cleanProps as any).tags = ['terminal', 'multmux']; break
|
case 'tags': (cleanProps as any).tags = ['terminal', 'multmux']; break
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,8 +156,8 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonBaseStyle: React.CSSProperties = {
|
const buttonBaseStyle: React.CSSProperties = {
|
||||||
width: '20px',
|
width: '24px',
|
||||||
height: '20px',
|
height: '24px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|
@ -170,8 +170,8 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
touchAction: 'manipulation', // Prevent double-tap zoom, improve touch responsiveness
|
touchAction: 'manipulation', // Prevent double-tap zoom, improve touch responsiveness
|
||||||
padding: '8px', // Increase touch target size without changing visual size
|
padding: 0,
|
||||||
margin: '-8px', // Negative margin to maintain visual spacing
|
margin: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
const minimizeButtonStyle: React.CSSProperties = {
|
const minimizeButtonStyle: React.CSSProperties = {
|
||||||
|
|
@ -222,7 +222,7 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagStyle: React.CSSProperties = {
|
const tagStyle: React.CSSProperties = {
|
||||||
backgroundColor: '#007acc',
|
backgroundColor: '#6b7280',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
padding: '4px 8px', // Increased padding for better touch target
|
padding: '4px 8px', // Increased padding for better touch target
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
|
|
@ -237,7 +237,7 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagInputStyle: React.CSSProperties = {
|
const tagInputStyle: React.CSSProperties = {
|
||||||
border: '1px solid #007acc',
|
border: '1px solid #9ca3af',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
|
|
@ -247,7 +247,7 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
||||||
}
|
}
|
||||||
|
|
||||||
const addTagButtonStyle: React.CSSProperties = {
|
const addTagButtonStyle: React.CSSProperties = {
|
||||||
backgroundColor: '#007acc',
|
backgroundColor: '#9ca3af',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
|
|
@ -320,8 +320,20 @@ export const StandardizedToolWrapper: React.FC<StandardizedToolWrapperProps> = (
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't stop the event - let tldraw handle it naturally
|
// CRITICAL: Switch to select tool and select this shape when dragging header
|
||||||
// The hand tool override will detect shapes and handle dragging
|
// 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) => {
|
const handleButtonClick = (e: React.MouseEvent, action: () => void) => {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import {
|
||||||
import React, { useState } from "react"
|
import React, { useState } from "react"
|
||||||
import { getRunPodConfig } from "@/lib/clientConfig"
|
import { getRunPodConfig } from "@/lib/clientConfig"
|
||||||
import { aiOrchestrator, isAIOrchestratorAvailable } from "@/lib/aiOrchestrator"
|
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
|
// Feature flag: Set to false when AI Orchestrator or RunPod API is ready for production
|
||||||
const USE_MOCK_API = false
|
const USE_MOCK_API = false
|
||||||
|
|
@ -44,6 +46,8 @@ type IImageGen = TLBaseShape<
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
endpointId?: string // Optional custom endpoint ID
|
endpointId?: string // Optional custom endpoint ID
|
||||||
|
tags: string[]
|
||||||
|
pinnedToView: boolean
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|
@ -274,6 +278,9 @@ async function pollRunPodJob(
|
||||||
export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
||||||
static override type = "ImageGen" as const
|
static override type = "ImageGen" as const
|
||||||
|
|
||||||
|
// Image generation theme color: Blue
|
||||||
|
static readonly PRIMARY_COLOR = "#007AFF"
|
||||||
|
|
||||||
MIN_WIDTH = 300 as const
|
MIN_WIDTH = 300 as const
|
||||||
MIN_HEIGHT = 300 as const
|
MIN_HEIGHT = 300 as const
|
||||||
DEFAULT_WIDTH = 400 as const
|
DEFAULT_WIDTH = 400 as const
|
||||||
|
|
@ -287,6 +294,8 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
||||||
imageUrl: null,
|
imageUrl: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
tags: ['image', 'ai-generated'],
|
||||||
|
pinnedToView: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,9 +311,19 @@ export class ImageGenShape extends BaseBoxShapeUtil<IImageGen> {
|
||||||
component(shape: IImageGen) {
|
component(shape: IImageGen) {
|
||||||
// Capture editor reference to avoid stale 'this' during drag operations
|
// Capture editor reference to avoid stale 'this' during drag operations
|
||||||
const editor = this.editor
|
const editor = this.editor
|
||||||
const [isHovering, setIsHovering] = useState(false)
|
|
||||||
const isSelected = 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<IImageGen>({
|
||||||
|
id: shape.id,
|
||||||
|
type: "ImageGen",
|
||||||
|
props: { pinnedToView: !shape.props.pinnedToView },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const generateImage = async (prompt: string) => {
|
const generateImage = async (prompt: string) => {
|
||||||
console.log("🎨 ImageGen: Generating image with prompt:", prompt)
|
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 (
|
return (
|
||||||
<HTMLContainer
|
<HTMLContainer id={shape.id}>
|
||||||
style={{
|
<StandardizedToolWrapper
|
||||||
borderRadius: 6,
|
title="🎨 Image Generator"
|
||||||
border: "1px solid lightgrey",
|
primaryColor={ImageGenShape.PRIMARY_COLOR}
|
||||||
padding: 8,
|
isSelected={isSelected}
|
||||||
height: shape.props.h,
|
width={shape.props.w}
|
||||||
width: shape.props.w,
|
height={shape.props.h}
|
||||||
pointerEvents: isSelected || isHovering ? "all" : "none",
|
onClose={handleClose}
|
||||||
backgroundColor: "#ffffff",
|
onMinimize={handleMinimize}
|
||||||
overflow: "hidden",
|
isMinimized={isMinimized}
|
||||||
display: "flex",
|
editor={editor}
|
||||||
flexDirection: "column",
|
shapeId={shape.id}
|
||||||
gap: 8,
|
tags={shape.props.tags || []}
|
||||||
}}
|
onTagsChange={handleTagsChange}
|
||||||
onPointerEnter={() => setIsHovering(true)}
|
tagsEditable={true}
|
||||||
onPointerLeave={() => setIsHovering(false)}
|
isPinnedToView={shape.props.pinnedToView}
|
||||||
>
|
onPinToggle={handlePinToggle}
|
||||||
{/* Error Display */}
|
headerContent={
|
||||||
{shape.props.error && (
|
shape.props.isLoading ? (
|
||||||
<div
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
style={{
|
🎨 Image Generator
|
||||||
padding: "12px 16px",
|
<span style={{
|
||||||
backgroundColor: "#fee",
|
marginLeft: 'auto',
|
||||||
border: "1px solid #fcc",
|
fontSize: '11px',
|
||||||
borderRadius: "8px",
|
color: ImageGenShape.PRIMARY_COLOR,
|
||||||
color: "#c33",
|
animation: 'pulse 1.5s ease-in-out infinite'
|
||||||
fontSize: "13px",
|
}}>
|
||||||
display: "flex",
|
Generating...
|
||||||
alignItems: "flex-start",
|
</span>
|
||||||
gap: "8px",
|
</span>
|
||||||
whiteSpace: "pre-wrap",
|
) : undefined
|
||||||
wordBreak: "break-word",
|
}
|
||||||
}}
|
>
|
||||||
>
|
<div style={{
|
||||||
<span style={{ fontSize: "18px", flexShrink: 0 }}>⚠️</span>
|
flex: 1,
|
||||||
<span style={{ flex: 1, lineHeight: "1.5" }}>{shape.props.error}</span>
|
display: 'flex',
|
||||||
<button
|
flexDirection: 'column',
|
||||||
onClick={() => {
|
padding: '12px',
|
||||||
editor.updateShape<IImageGen>({
|
gap: '12px',
|
||||||
id: shape.id,
|
overflow: 'auto',
|
||||||
type: "ImageGen",
|
backgroundColor: '#fafafa'
|
||||||
props: { error: null },
|
}}>
|
||||||
})
|
{/* 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={{
|
style={{
|
||||||
padding: "4px 8px",
|
display: "flex",
|
||||||
backgroundColor: "#fcc",
|
gap: 8,
|
||||||
border: "1px solid #c99",
|
|
||||||
borderRadius: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "11px",
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Dismiss
|
<input
|
||||||
</button>
|
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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Image Display */}
|
{/* Add CSS for spinner animation */}
|
||||||
{shape.props.imageUrl && !shape.props.isLoading && (
|
<style>{`
|
||||||
<div
|
@keyframes spin {
|
||||||
style={{
|
0% { transform: rotate(0deg); }
|
||||||
flex: 1,
|
100% { transform: rotate(360deg); }
|
||||||
display: "flex",
|
}
|
||||||
alignItems: "center",
|
@keyframes pulse {
|
||||||
justifyContent: "center",
|
0%, 100% { opacity: 1; }
|
||||||
backgroundColor: "#f5f5f5",
|
50% { opacity: 0.5; }
|
||||||
borderRadius: "4px",
|
}
|
||||||
overflow: "hidden",
|
`}</style>
|
||||||
minHeight: 0,
|
</StandardizedToolWrapper>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</HTMLContainer>
|
</HTMLContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ function httpToWs(httpUrl: string): string {
|
||||||
const versions = createShapePropsMigrationIds('Multmux', {
|
const versions = createShapePropsMigrationIds('Multmux', {
|
||||||
AddMissingProps: 1,
|
AddMissingProps: 1,
|
||||||
RemoveWsUrl: 2,
|
RemoveWsUrl: 2,
|
||||||
|
UpdateServerPort: 3,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Migrations to handle shapes with missing/undefined props
|
// Migrations to handle shapes with missing/undefined props
|
||||||
|
|
@ -82,6 +83,21 @@ export const multmuxShapeMigrations = createShapePropsMigrationSequence({
|
||||||
wsUrl: httpToWs(props.serverUrl || 'http://localhost:3002'),
|
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
|
// Use the pinning hook
|
||||||
usePinnedToView(this.editor, shape.id, shape.props.pinnedToView)
|
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 = () => {
|
const handleClose = () => {
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close()
|
ws.close()
|
||||||
|
|
|
||||||
|
|
@ -1047,7 +1047,8 @@ export class ObsNoteShape extends BaseBoxShapeUtil<IObsNoteShape> {
|
||||||
noteId: typeof props.noteId === 'string' ? props.noteId : '',
|
noteId: typeof props.noteId === 'string' ? props.noteId : '',
|
||||||
title: typeof props.title === 'string' ? props.title : 'Untitled ObsNote',
|
title: typeof props.title === 'string' ? props.title : 'Untitled ObsNote',
|
||||||
content: typeof props.content === 'string' ? props.content : '',
|
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,
|
showPreview: typeof props.showPreview === 'boolean' ? props.showPreview : true,
|
||||||
backgroundColor: typeof props.backgroundColor === 'string' ? props.backgroundColor : '#ffffff',
|
backgroundColor: typeof props.backgroundColor === 'string' ? props.backgroundColor : '#ffffff',
|
||||||
textColor: typeof props.textColor === 'string' ? props.textColor : '#000000',
|
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
|
* 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 {
|
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
|
// Use sanitizeProps to ensure all values are JSON serializable
|
||||||
const props = ObsNoteShape.sanitizeProps({
|
const props = ObsNoteShape.sanitizeProps({
|
||||||
w: 300,
|
w: 300,
|
||||||
|
|
@ -1157,7 +1164,7 @@ export class ObsNoteShape extends BaseBoxShapeUtil<IObsNoteShape> {
|
||||||
noteId: obs_note.id || '',
|
noteId: obs_note.id || '',
|
||||||
title: obs_note.title || 'Untitled',
|
title: obs_note.title || 'Untitled',
|
||||||
content: obs_note.content || '',
|
content: obs_note.content || '',
|
||||||
tags: obs_note.tags || [],
|
tags: obsidianTags,
|
||||||
showPreview: true,
|
showPreview: true,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
textColor: '#000000',
|
textColor: '#000000',
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
import React, { useState } from "react"
|
import React, { useState } from "react"
|
||||||
import { getRunPodVideoConfig } from "@/lib/clientConfig"
|
import { getRunPodVideoConfig } from "@/lib/clientConfig"
|
||||||
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
|
import { StandardizedToolWrapper } from "@/components/StandardizedToolWrapper"
|
||||||
|
import { usePinnedToView } from "@/hooks/usePinnedToView"
|
||||||
|
|
||||||
// Type for RunPod job response
|
// Type for RunPod job response
|
||||||
interface RunPodJobResponse {
|
interface RunPodJobResponse {
|
||||||
|
|
@ -33,6 +34,7 @@ type IVideoGen = TLBaseShape<
|
||||||
duration: number // seconds
|
duration: number // seconds
|
||||||
model: string
|
model: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
|
pinnedToView: boolean
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|
@ -52,7 +54,8 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
||||||
error: null,
|
error: null,
|
||||||
duration: 3,
|
duration: 3,
|
||||||
model: "wan2.1-i2v",
|
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) {
|
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 [prompt, setPrompt] = useState(shape.props.prompt)
|
||||||
const [isGenerating, setIsGenerating] = useState(shape.props.isLoading)
|
const [isGenerating, setIsGenerating] = useState(shape.props.isLoading)
|
||||||
const [error, setError] = useState<string | null>(shape.props.error)
|
const [error, setError] = useState<string | null>(shape.props.error)
|
||||||
const [videoUrl, setVideoUrl] = useState<string | null>(shape.props.videoUrl)
|
const [videoUrl, setVideoUrl] = useState<string | null>(shape.props.videoUrl)
|
||||||
const [isMinimized, setIsMinimized] = useState(false)
|
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 () => {
|
const handleGenerate = async () => {
|
||||||
if (!prompt.trim()) {
|
if (!prompt.trim()) {
|
||||||
|
|
@ -91,7 +107,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
// Update shape to show loading state
|
// Update shape to show loading state
|
||||||
this.editor.updateShape({
|
editor.updateShape({
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
type: shape.type,
|
type: shape.type,
|
||||||
props: { ...shape.props, isLoading: true, error: null }
|
props: { ...shape.props, isLoading: true, error: null }
|
||||||
|
|
@ -186,7 +202,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
||||||
setVideoUrl(url)
|
setVideoUrl(url)
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
|
|
||||||
this.editor.updateShape({
|
editor.updateShape({
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
type: shape.type,
|
type: shape.type,
|
||||||
props: {
|
props: {
|
||||||
|
|
@ -215,7 +231,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
||||||
setError(errorMessage)
|
setError(errorMessage)
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
|
|
||||||
this.editor.updateShape({
|
editor.updateShape({
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
type: shape.type,
|
type: shape.type,
|
||||||
props: { ...shape.props, isLoading: false, error: errorMessage }
|
props: { ...shape.props, isLoading: false, error: errorMessage }
|
||||||
|
|
@ -224,7 +240,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
this.editor.deleteShape(shape.id)
|
editor.deleteShape(shape.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMinimize = () => {
|
const handleMinimize = () => {
|
||||||
|
|
@ -232,7 +248,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTagsChange = (newTags: string[]) => {
|
const handleTagsChange = (newTags: string[]) => {
|
||||||
this.editor.updateShape({
|
editor.updateShape({
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
type: shape.type,
|
type: shape.type,
|
||||||
props: { ...shape.props, tags: newTags }
|
props: { ...shape.props, tags: newTags }
|
||||||
|
|
@ -250,11 +266,13 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
onMinimize={handleMinimize}
|
onMinimize={handleMinimize}
|
||||||
isMinimized={isMinimized}
|
isMinimized={isMinimized}
|
||||||
editor={this.editor}
|
editor={editor}
|
||||||
shapeId={shape.id}
|
shapeId={shape.id}
|
||||||
tags={shape.props.tags}
|
tags={shape.props.tags}
|
||||||
onTagsChange={handleTagsChange}
|
onTagsChange={handleTagsChange}
|
||||||
tagsEditable={true}
|
tagsEditable={true}
|
||||||
|
isPinnedToView={shape.props.pinnedToView}
|
||||||
|
onPinToggle={handlePinToggle}
|
||||||
headerContent={
|
headerContent={
|
||||||
isGenerating ? (
|
isGenerating ? (
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
|
@ -320,7 +338,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
||||||
max="10"
|
max="10"
|
||||||
value={shape.props.duration}
|
value={shape.props.duration}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
this.editor.updateShape({
|
editor.updateShape({
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
type: shape.type,
|
type: shape.type,
|
||||||
props: { ...shape.props, duration: parseInt(e.target.value) || 3 }
|
props: { ...shape.props, duration: parseInt(e.target.value) || 3 }
|
||||||
|
|
@ -429,7 +447,7 @@ export class VideoGenShape extends BaseBoxShapeUtil<IVideoGen> {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setVideoUrl(null)
|
setVideoUrl(null)
|
||||||
setPrompt("")
|
setPrompt("")
|
||||||
this.editor.updateShape({
|
editor.updateShape({
|
||||||
id: shape.id,
|
id: shape.id,
|
||||||
type: shape.type,
|
type: shape.type,
|
||||||
props: { ...shape.props, videoUrl: null, prompt: "" }
|
props: { ...shape.props, videoUrl: null, prompt: "" }
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ export class MultmuxIdle extends StateNode {
|
||||||
sessionId: '',
|
sessionId: '',
|
||||||
sessionName: '',
|
sessionName: '',
|
||||||
token: '',
|
token: '',
|
||||||
serverUrl: 'http://localhost:3000',
|
serverUrl: 'http://localhost:3002',
|
||||||
pinnedToView: false,
|
pinnedToView: false,
|
||||||
tags: ['terminal', 'multmux'],
|
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'
|
binding = 'BOARD_BACKUPS_BUCKET'
|
||||||
bucket_name = 'board-backups'
|
bucket_name = 'board-backups'
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "CRYPTID_DB"
|
||||||
|
database_name = "cryptid-auth"
|
||||||
|
database_id = "placeholder-will-be-created"
|
||||||
|
|
||||||
[observability]
|
[observability]
|
||||||
enabled = true
|
enabled = true
|
||||||
head_sampling_rate = 1
|
head_sampling_rate = 1
|
||||||
|
|
@ -91,6 +96,11 @@ bucket_name = 'jeffemmett-canvas-preview'
|
||||||
binding = 'BOARD_BACKUPS_BUCKET'
|
binding = 'BOARD_BACKUPS_BUCKET'
|
||||||
bucket_name = 'board-backups-preview'
|
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]
|
[env.dev.triggers]
|
||||||
crons = ["0 0 * * *"] # Run at midnight UTC every day
|
crons = ["0 0 * * *"] # Run at midnight UTC every day
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue