feat: add user dropdown menu, fix auth tool visibility, improve network graph

- Add dropdown menu when clicking user nodes in network graph with options:
  - Connect with <username>
  - Navigate to <username> (pan to cursor)
  - Screenfollow <username> (follow camera)
  - Open <username>'s profile
- Fix tool visibility for logged-in users (timing issue with read-only mode)
- Fix 401 errors by correcting localStorage key from 'cryptid_session' to 'canvas_auth_session'
- Remove "(anonymous)" suffix from usernames in tooltips
- Simplify node colors to use user's profile/presence color
- Clear permission cache on logout to prevent stale state
- Various UI improvements to auth components and network graph

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-12 18:41:53 -05:00
parent 1136047795
commit e4b767625c
34 changed files with 5616 additions and 1665 deletions

View File

@ -274,12 +274,7 @@ export function useAutomergeStoreV2({
return
}
// Broadcasting changes via JSON sync
const shapeRecords = addedOrUpdatedRecords.filter(r => r?.typeName === 'shape')
const deletedShapes = deletedRecordIds.filter(id => id.startsWith('shape:'))
if (shapeRecords.length > 0 || deletedShapes.length > 0) {
console.log(`📤 Broadcasting ${shapeRecords.length} shape changes and ${deletedShapes.length} deletions via JSON sync`)
}
// Broadcasting changes via JSON sync (logging disabled for performance)
if (adapter && typeof (adapter as any).send === 'function') {
// Send changes to other clients via the network adapter
@ -303,50 +298,23 @@ export function useAutomergeStoreV2({
// Listen for changes from Automerge and apply them to TLDraw
const automergeChangeHandler = (payload: DocHandleChangePayload<any>) => {
const patchCount = payload.patches?.length || 0
const shapePatches = payload.patches?.filter((p: any) => {
const id = p.path?.[1]
return id && typeof id === 'string' && id.startsWith('shape:')
}) || []
// Debug logging for sync issues
console.log(`🔄 automergeChangeHandler: ${patchCount} patches (${shapePatches.length} shapes), pendingLocalChanges=${pendingLocalChanges}`)
// Skip echoes of our own local changes using a counter.
// Each local handle.change() increments the counter, and each echo decrements it.
// Only process changes when counter is 0 (those are remote changes from other clients).
if (pendingLocalChanges > 0) {
console.log(`⏭️ Skipping echo (pendingLocalChanges was ${pendingLocalChanges}, now ${pendingLocalChanges - 1})`)
pendingLocalChanges--
return
}
console.log(`✅ Processing ${patchCount} patches as REMOTE changes (${shapePatches.length} shape patches)`)
try {
// Apply patches from Automerge to TLDraw store
if (payload.patches && payload.patches.length > 0) {
// Debug: Check if patches contain shapes
if (shapePatches.length > 0) {
console.log(`📥 Applying ${shapePatches.length} shape patches from remote`)
}
try {
const recordsBefore = store.allRecords()
const shapesBefore = recordsBefore.filter((r: any) => r.typeName === 'shape')
// CRITICAL: Pass Automerge document to patch handler so it can read full records
// This prevents coordinates from defaulting to 0,0 when patches create new records
const automergeDoc = handle.doc()
applyAutomergePatchesToTLStore(payload.patches, store, automergeDoc)
const recordsAfter = store.allRecords()
const shapesAfter = recordsAfter.filter((r: any) => r.typeName === 'shape')
if (shapesAfter.length !== shapesBefore.length) {
// Patches applied
}
// Patches processed successfully
} catch (patchError) {
console.error("Error applying patches batch, attempting individual patch application:", patchError)
// Try applying patches one by one to identify problematic ones
@ -580,78 +548,91 @@ export function useAutomergeStoreV2({
// Track recent eraser activity to detect active eraser drags
let lastEraserActivity = 0
let eraserToolSelected = false
let lastEraserCheckTime = 0
let cachedEraserActive = false
const ERASER_ACTIVITY_THRESHOLD = 2000 // Increased to 2 seconds to handle longer eraser drags
const ERASER_CHECK_CACHE_MS = 100 // Only refresh eraser state every 100ms to avoid expensive checks
let eraserChangeQueue: RecordsDiff<TLRecord> | null = null
let eraserCheckInterval: NodeJS.Timeout | null = null
// Helper to check if eraser tool is actively erasing (to prevent saves during eraser drag)
// OPTIMIZED: Uses cached state and only refreshes periodically to avoid expensive store.allRecords() calls
const isEraserActive = (): boolean => {
const now = Date.now()
// Use cached result if checked recently
if (now - lastEraserCheckTime < ERASER_CHECK_CACHE_MS) {
return cachedEraserActive
}
lastEraserCheckTime = now
// If eraser was selected and recent activity, assume still active
if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
cachedEraserActive = true
return true
}
// If no recent eraser activity and not marked as selected, quickly return false
if (!eraserToolSelected && now - lastEraserActivity > ERASER_ACTIVITY_THRESHOLD) {
cachedEraserActive = false
return false
}
// Only do expensive check if eraser might be transitioning
try {
const allRecords = store.allRecords()
// Use store.get() for specific records instead of allRecords() for better performance
const instancePageState = store.get('instance_page_state:page:page' as any)
// Check instance_page_state for erasingShapeIds (most reliable indicator)
const instancePageState = allRecords.find((r: any) =>
r.typeName === 'instance_page_state' &&
(r as any).erasingShapeIds &&
Array.isArray((r as any).erasingShapeIds) &&
(r as any).erasingShapeIds.length > 0
)
if (instancePageState) {
lastEraserActivity = Date.now()
if (instancePageState &&
(instancePageState as any).erasingShapeIds &&
Array.isArray((instancePageState as any).erasingShapeIds) &&
(instancePageState as any).erasingShapeIds.length > 0) {
lastEraserActivity = now
eraserToolSelected = true
cachedEraserActive = true
return true // Eraser is actively erasing shapes
}
// Check if eraser tool is selected
const instance = allRecords.find((r: any) => r.typeName === 'instance')
const instance = store.get('instance:instance' as any)
const currentToolId = instance ? (instance as any).currentToolId : null
if (currentToolId === 'eraser') {
eraserToolSelected = true
const now = Date.now()
// If eraser tool is selected, keep it active for longer to handle drags
// Also check if there was recent activity
if (now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
return true
}
// If tool is selected but no recent activity, still consider it active
// (user might be mid-drag)
lastEraserActivity = now
cachedEraserActive = true
return true
} else {
// Tool switched away - only consider active if very recent activity
eraserToolSelected = false
const now = Date.now()
if (now - lastEraserActivity < 300) {
return true // Very recent activity, might still be processing
}
}
cachedEraserActive = false
return false
} catch (e) {
// If we can't check, use last known state with timeout
const now = Date.now()
if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
cachedEraserActive = true
return true
}
cachedEraserActive = false
return false
}
}
// Track eraser activity from shape deletions
// OPTIMIZED: Only check for eraser tool when shapes are removed, and use cached tool state
const checkForEraserActivity = (changes: RecordsDiff<TLRecord>) => {
// If shapes are being removed and eraser tool might be active, mark activity
if (changes.removed) {
const removedShapes = Object.values(changes.removed).filter((r: any) =>
r && r.typeName === 'shape'
)
if (removedShapes.length > 0) {
// Check if eraser tool is currently selected
const allRecords = store.allRecords()
const instance = allRecords.find((r: any) => r.typeName === 'instance')
if (instance && (instance as any).currentToolId === 'eraser') {
lastEraserActivity = Date.now()
eraserToolSelected = true
const removedKeys = Object.keys(changes.removed)
// Quick check: if no shape keys, skip
const hasRemovedShapes = removedKeys.some(key => key.startsWith('shape:'))
if (hasRemovedShapes) {
// Use cached eraserToolSelected state if recent, avoid expensive allRecords() call
const now = Date.now()
if (eraserToolSelected || now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
lastEraserActivity = now
}
}
}
@ -688,17 +669,6 @@ export function useAutomergeStoreV2({
id.startsWith('pointer:')
)
// DEBUG: Log why records are being filtered or not
const shouldFilter = (typeName && ephemeralTypes.includes(typeName)) || idMatchesEphemeral
if (shouldFilter) {
console.log(`🚫 Filtering out ephemeral record:`, {
id,
typeName,
idMatchesEphemeral,
typeNameMatches: typeName && ephemeralTypes.includes(typeName)
})
}
// Filter out if typeName matches OR if ID pattern matches ephemeral types
if (typeName && ephemeralTypes.includes(typeName)) {
// Skip - this is an ephemeral record
@ -721,183 +691,9 @@ export function useAutomergeStoreV2({
removed: filterEphemeral(changes.removed),
}
// DEBUG: Log all changes to see what's being detected
const totalChanges = Object.keys(changes.added || {}).length + Object.keys(changes.updated || {}).length + Object.keys(changes.removed || {}).length
// Calculate change counts (minimal, needed for early return)
const filteredTotalChanges = Object.keys(filteredChanges.added || {}).length + Object.keys(filteredChanges.updated || {}).length + Object.keys(filteredChanges.removed || {}).length
// DEBUG: Log ALL changes (before filtering) to see what's actually being updated
if (totalChanges > 0) {
const allChangedRecords: Array<{id: string, typeName: string, changeType: string}> = []
if (changes.added) {
Object.entries(changes.added).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
allChangedRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'added' })
})
}
if (changes.updated) {
Object.entries(changes.updated).forEach(([id, [_, record]]: [string, [any, any]]) => {
allChangedRecords.push({ id, typeName: record?.typeName || 'unknown', changeType: 'updated' })
})
}
if (changes.removed) {
Object.entries(changes.removed).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
allChangedRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'removed' })
})
}
console.log(`🔍 ALL changes detected (before filtering):`, {
total: totalChanges,
records: allChangedRecords,
// Also log the actual record objects to see their structure
recordDetails: allChangedRecords.map(r => {
let record: any = null
if (r.changeType === 'added' && changes.added) {
const rec = (changes.added as any)[r.id]
record = Array.isArray(rec) ? rec[1] : rec
} else if (r.changeType === 'updated' && changes.updated) {
const rec = (changes.updated as any)[r.id]
record = Array.isArray(rec) ? rec[1] : rec
} else if (r.changeType === 'removed' && changes.removed) {
const rec = (changes.removed as any)[r.id]
record = Array.isArray(rec) ? rec[1] : rec
}
return {
id: r.id,
typeName: r.typeName,
changeType: r.changeType,
hasTypeName: !!record?.typeName,
actualTypeName: record?.typeName,
recordKeys: record ? Object.keys(record).slice(0, 10) : []
}
})
})
}
// Log if we filtered out any ephemeral changes
if (totalChanges > 0 && filteredTotalChanges < totalChanges) {
const filteredCount = totalChanges - filteredTotalChanges
const filteredTypes = new Set<string>()
const filteredIds: string[] = []
if (changes.added) {
Object.entries(changes.added).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
if (recordObj && ephemeralTypes.includes(recordObj.typeName)) {
filteredTypes.add(recordObj.typeName)
filteredIds.push(id)
}
})
}
if (changes.updated) {
Object.entries(changes.updated).forEach(([id, [_, record]]: [string, [any, any]]) => {
if (ephemeralTypes.includes(record.typeName)) {
filteredTypes.add(record.typeName)
filteredIds.push(id)
}
})
}
if (changes.removed) {
Object.entries(changes.removed).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
if (recordObj && ephemeralTypes.includes(recordObj.typeName)) {
filteredTypes.add(recordObj.typeName)
filteredIds.push(id)
}
})
}
console.log(`🚫 Filtered out ${filteredCount} ephemeral change(s) (${Array.from(filteredTypes).join(', ')}) - not persisting`, {
filteredIds: filteredIds.slice(0, 5), // Show first 5 IDs
totalFiltered: filteredIds.length
})
}
if (filteredTotalChanges > 0) {
// Log what records are passing through the filter (shouldn't happen for ephemeral records)
const passingRecords: Array<{id: string, typeName: string, changeType: string}> = []
if (filteredChanges.added) {
Object.entries(filteredChanges.added).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
passingRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'added' })
})
}
if (filteredChanges.updated) {
Object.entries(filteredChanges.updated).forEach(([id, recordTuple]: [string, any]) => {
const record = Array.isArray(recordTuple) && recordTuple.length === 2 ? recordTuple[1] : recordTuple
passingRecords.push({ id, typeName: (record as any)?.typeName || 'unknown', changeType: 'updated' })
})
}
if (filteredChanges.removed) {
Object.entries(filteredChanges.removed).forEach(([id, record]: [string, any]) => {
const recordObj = Array.isArray(record) ? record[1] : record
passingRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'removed' })
})
}
console.log(`🔍 TLDraw store changes detected (source: ${source}):`, {
added: Object.keys(filteredChanges.added || {}).length,
updated: Object.keys(filteredChanges.updated || {}).length,
removed: Object.keys(filteredChanges.removed || {}).length,
source: source,
passingRecords: passingRecords // Show what's actually passing through
})
// DEBUG: Check for richText/text changes in updated records
if (filteredChanges.updated) {
Object.values(filteredChanges.updated).forEach((recordTuple: any) => {
const record = Array.isArray(recordTuple) && recordTuple.length === 2 ? recordTuple[1] : recordTuple
if ((record as any)?.typeName === 'shape') {
const rec = record as any
if (rec.type === 'geo' && rec.props?.richText) {
console.log(`🔍 Geo shape ${rec.id} richText change detected:`, {
hasRichText: !!rec.props.richText,
richTextType: typeof rec.props.richText,
source: source
})
}
if (rec.type === 'note' && rec.props?.richText) {
console.log(`🔍 Note shape ${rec.id} richText change detected:`, {
hasRichText: !!rec.props.richText,
richTextType: typeof rec.props.richText,
richTextContentLength: Array.isArray(rec.props.richText?.content)
? rec.props.richText.content.length
: 'not array',
source: source
})
}
if (rec.type === 'arrow' && rec.props?.text !== undefined) {
console.log(`🔍 Arrow shape ${rec.id} text change detected:`, {
hasText: !!rec.props.text,
textValue: rec.props.text,
source: source
})
}
if (rec.type === 'text' && rec.props?.richText) {
console.log(`🔍 Text shape ${rec.id} richText change detected:`, {
hasRichText: !!rec.props.richText,
richTextType: typeof rec.props.richText,
source: source
})
}
}
})
}
// DEBUG: Log added shapes to track what's being created
if (filteredChanges.added) {
Object.values(filteredChanges.added).forEach((record: any) => {
const rec = Array.isArray(record) ? record[1] : record
if (rec?.typeName === 'shape') {
console.log(`🔍 Shape added: ${rec.type} (${rec.id})`, {
type: rec.type,
id: rec.id,
hasRichText: !!rec.props?.richText,
hasText: !!rec.props?.text,
source: source
})
}
})
}
}
// Skip if no meaningful changes after filtering ephemeral records
if (filteredTotalChanges === 0) {
return
@ -906,7 +702,6 @@ export function useAutomergeStoreV2({
// CRITICAL: Skip broadcasting changes that came from remote sources to prevent feedback loops
// Only broadcast changes that originated from user interactions (source === 'user')
if (source === 'remote') {
console.log('🔄 Skipping broadcast for remote change to prevent feedback loop')
return
}
@ -1044,38 +839,6 @@ export function useAutomergeStoreV2({
// Check if this is a position-only update that should be throttled
const isPositionOnly = isPositionOnlyUpdate(finalFilteredChanges)
// Log what type of change this is for debugging
const changeType = Object.keys(finalFilteredChanges.added || {}).length > 0 ? 'added' :
Object.keys(finalFilteredChanges.removed || {}).length > 0 ? 'removed' :
isPositionOnly ? 'position-only' : 'property-change'
// DEBUG: Log dimension changes for shapes
if (finalFilteredChanges.updated) {
Object.entries(finalFilteredChanges.updated).forEach(([id, recordTuple]: [string, any]) => {
const isTuple = Array.isArray(recordTuple) && recordTuple.length === 2
const oldRecord = isTuple ? recordTuple[0] : null
const newRecord = isTuple ? recordTuple[1] : recordTuple
if (newRecord?.typeName === 'shape') {
const oldProps = oldRecord?.props || {}
const newProps = newRecord?.props || {}
if (oldProps.w !== newProps.w || oldProps.h !== newProps.h) {
console.log(`🔍 Shape dimension change detected for ${newRecord.type} ${id}:`, {
oldDims: { w: oldProps.w, h: oldProps.h },
newDims: { w: newProps.w, h: newProps.h },
source
})
}
}
})
}
console.log(`🔍 Change detected: ${changeType}, will ${isPositionOnly ? 'throttle' : 'broadcast immediately'}`, {
added: Object.keys(finalFilteredChanges.added || {}).length,
updated: Object.keys(finalFilteredChanges.updated || {}).length,
removed: Object.keys(finalFilteredChanges.removed || {}).length,
source
})
if (isPositionOnly && positionUpdateQueue === null) {
// Start a new queue for position updates
positionUpdateQueue = finalFilteredChanges
@ -1258,12 +1021,7 @@ export function useAutomergeStoreV2({
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
}
// Only log if there are many changes or if debugging is needed
if (filteredTotalChanges > 3) {
console.log(`✅ Applied ${filteredTotalChanges} TLDraw changes to Automerge document`)
} else if (filteredTotalChanges > 0) {
console.log(`✅ Applied ${filteredTotalChanges} TLDraw change(s) to Automerge document`)
}
// Logging disabled for performance during continuous drawing
// Check if the document actually changed
const docAfter = handle.doc()

View File

@ -203,6 +203,31 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
console.log(`✅ Applied ${changedRecordCount} records and ${deletedRecordIds.length} deletions to Automerge document`)
}, [])
// Presence update batching to prevent "Maximum update depth exceeded" errors
// We batch presence updates and apply them in a single mergeRemoteChanges call
const pendingPresenceUpdates = useRef<Map<string, any>>(new Map())
const presenceUpdateTimer = useRef<NodeJS.Timeout | null>(null)
const PRESENCE_BATCH_INTERVAL_MS = 16 // ~60fps, batch updates every frame
// Flush pending presence updates to the store
const flushPresenceUpdates = useCallback(() => {
const currentStore = storeRef.current
if (!currentStore || pendingPresenceUpdates.current.size === 0) {
return
}
const updates = Array.from(pendingPresenceUpdates.current.values())
pendingPresenceUpdates.current.clear()
try {
currentStore.mergeRemoteChanges(() => {
currentStore.put(updates)
})
} catch (error) {
console.error('❌ Error flushing presence updates:', error)
}
}, [])
// Presence update callback - applies presence from other clients
// Presence is ephemeral (cursors, selections) and goes directly to the store
// Note: This callback is passed to the adapter but accesses storeRef which updates later
@ -256,16 +281,21 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
lastActivityTimestamp: Date.now()
})
// Apply the instance_presence record using mergeRemoteChanges for atomic updates
currentStore.mergeRemoteChanges(() => {
currentStore.put([instancePresence])
})
// Queue the presence update for batched application
pendingPresenceUpdates.current.set(presenceId, instancePresence)
// Schedule a flush if not already scheduled
if (!presenceUpdateTimer.current) {
presenceUpdateTimer.current = setTimeout(() => {
presenceUpdateTimer.current = null
flushPresenceUpdates()
}, PRESENCE_BATCH_INTERVAL_MS)
}
// Presence applied for remote user
} catch (error) {
console.error('❌ Error applying presence:', error)
}
}, [])
}, [flushPresenceUpdates])
const { repo, adapter, storageAdapter } = useMemo(() => {
const adapter = new CloudflareNetworkAdapter(
@ -541,6 +571,11 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
return () => {
mounted = false
// Clear any pending presence update timer
if (presenceUpdateTimer.current) {
clearTimeout(presenceUpdateTimer.current)
presenceUpdateTimer.current = null
}
// Disconnect adapter on unmount to clean up WebSocket connection
if (adapter) {
adapter.disconnect?.()

View File

@ -0,0 +1,477 @@
/**
* Miro Import Dialog
*
* A dialog component for importing Miro boards into the tldraw canvas.
* Supports both JSON file upload and pasting JSON directly.
*/
import { useState, useCallback, useRef } from 'react'
import { useEditor } from 'tldraw'
import { importMiroJson, isValidMiroUrl, MiroImportResult } from '@/lib/miroImport'
interface MiroImportDialogProps {
isOpen: boolean
onClose: () => void
}
type ImportMethod = 'json-file' | 'json-paste'
export function MiroImportDialog({ isOpen, onClose }: MiroImportDialogProps) {
const editor = useEditor()
const fileInputRef = useRef<HTMLInputElement>(null)
const [importMethod, setImportMethod] = useState<ImportMethod>('json-file')
const [jsonText, setJsonText] = useState('')
const [isImporting, setIsImporting] = useState(false)
const [progress, setProgress] = useState({ stage: '', percent: 0 })
const [result, setResult] = useState<MiroImportResult | null>(null)
const [error, setError] = useState<string | null>(null)
const resetState = useCallback(() => {
setJsonText('')
setIsImporting(false)
setProgress({ stage: '', percent: 0 })
setResult(null)
setError(null)
}, [])
const handleClose = useCallback(() => {
resetState()
onClose()
}, [onClose, resetState])
const handleImport = useCallback(async (jsonString: string) => {
setIsImporting(true)
setError(null)
setResult(null)
try {
// Get current viewport center for import offset
const viewportBounds = editor.getViewportPageBounds()
const offset = {
x: viewportBounds.x + viewportBounds.w / 2,
y: viewportBounds.y + viewportBounds.h / 2,
}
const importResult = await importMiroJson(
jsonString,
{
migrateAssets: true,
offset,
},
{
onProgress: (stage, percent) => {
setProgress({ stage, percent: Math.round(percent * 100) })
},
}
)
setResult(importResult)
if (importResult.success && importResult.shapes.length > 0) {
// Create assets first
if (importResult.assets.length > 0) {
for (const asset of importResult.assets) {
try {
editor.createAssets([asset])
} catch (e) {
console.warn('Failed to create asset:', e)
}
}
}
// Create shapes
editor.createShapes(importResult.shapes)
// Select and zoom to imported shapes
const shapeIds = importResult.shapes.map((s: any) => s.id)
editor.setSelectedShapes(shapeIds)
editor.zoomToSelection()
}
} catch (e) {
console.error('Import error:', e)
setError(e instanceof Error ? e.message : 'Failed to import Miro board')
} finally {
setIsImporting(false)
}
}, [editor])
const handleFileSelect = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
const text = await file.text()
await handleImport(text)
} catch (e) {
setError('Failed to read file')
}
// Reset file input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}, [handleImport])
const handlePasteImport = useCallback(() => {
if (!jsonText.trim()) {
setError('Please paste Miro JSON data')
return
}
handleImport(jsonText)
}, [jsonText, handleImport])
if (!isOpen) return null
return (
<div className="miro-import-overlay" onClick={handleClose}>
<div className="miro-import-dialog" onClick={(e) => e.stopPropagation()}>
<div className="miro-import-header">
<h2>Import from Miro</h2>
<button className="miro-import-close" onClick={handleClose}>
&times;
</button>
</div>
<div className="miro-import-content">
{/* Import Method Tabs */}
<div className="miro-import-tabs">
<button
className={`miro-import-tab ${importMethod === 'json-file' ? 'active' : ''}`}
onClick={() => setImportMethod('json-file')}
disabled={isImporting}
>
Upload JSON File
</button>
<button
className={`miro-import-tab ${importMethod === 'json-paste' ? 'active' : ''}`}
onClick={() => setImportMethod('json-paste')}
disabled={isImporting}
>
Paste JSON
</button>
</div>
{/* JSON File Upload */}
{importMethod === 'json-file' && (
<div className="miro-import-section">
<p className="miro-import-help">
Upload a JSON file exported from Miro using the{' '}
<a href="https://github.com/jolle/miro-export" target="_blank" rel="noopener noreferrer">
miro-export
</a>{' '}
CLI tool:
</p>
<pre className="miro-import-code">
npx miro-export -b YOUR_BOARD_ID -e json -o board.json
</pre>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelect}
disabled={isImporting}
className="miro-import-file-input"
/>
<button
className="miro-import-button"
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
>
Choose JSON File
</button>
</div>
)}
{/* JSON Paste */}
{importMethod === 'json-paste' && (
<div className="miro-import-section">
<p className="miro-import-help">
Paste your Miro board JSON data below:
</p>
<textarea
className="miro-import-textarea"
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
placeholder='[{"type":"sticky_note","id":"...","x":0,"y":0,...}]'
disabled={isImporting}
rows={10}
/>
<button
className="miro-import-button"
onClick={handlePasteImport}
disabled={isImporting || !jsonText.trim()}
>
Import
</button>
</div>
)}
{/* Progress */}
{isImporting && (
<div className="miro-import-progress">
<div className="miro-import-progress-bar">
<div
className="miro-import-progress-fill"
style={{ width: `${progress.percent}%` }}
/>
</div>
<p className="miro-import-progress-text">{progress.stage}</p>
</div>
)}
{/* Error */}
{error && (
<div className="miro-import-error">
<strong>Error:</strong> {error}
</div>
)}
{/* Result */}
{result && (
<div className={`miro-import-result ${result.success ? 'success' : 'failed'}`}>
{result.success ? (
<>
<p>Successfully imported {result.shapesCreated} shapes!</p>
{result.assetsUploaded > 0 && (
<p>Migrated {result.assetsUploaded} images to local storage.</p>
)}
{result.errors.length > 0 && (
<p className="miro-import-warnings">
Warnings: {result.errors.join(', ')}
</p>
)}
<button className="miro-import-button" onClick={handleClose}>
Done
</button>
</>
) : (
<>
<p>Import failed: {result.errors.join(', ')}</p>
<button className="miro-import-button" onClick={resetState}>
Try Again
</button>
</>
)}
</div>
)}
</div>
</div>
<style>{`
.miro-import-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.miro-import-dialog {
background: var(--color-panel, white);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.miro-import-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--color-divider, #eee);
}
.miro-import-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.miro-import-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--color-text, #333);
padding: 0;
line-height: 1;
}
.miro-import-content {
padding: 20px;
}
.miro-import-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.miro-import-tab {
flex: 1;
padding: 10px 16px;
border: 1px solid var(--color-divider, #ddd);
background: var(--color-background, #f5f5f5);
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.miro-import-tab:hover:not(:disabled) {
background: var(--color-muted-1, #e0e0e0);
}
.miro-import-tab.active {
background: var(--color-primary, #2563eb);
color: white;
border-color: var(--color-primary, #2563eb);
}
.miro-import-tab:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.miro-import-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.miro-import-help {
margin: 0;
font-size: 14px;
color: var(--color-text-1, #666);
line-height: 1.5;
}
.miro-import-help a {
color: var(--color-primary, #2563eb);
}
.miro-import-code {
background: var(--color-background, #f5f5f5);
padding: 12px;
border-radius: 6px;
font-family: monospace;
font-size: 12px;
overflow-x: auto;
margin: 0;
}
.miro-import-file-input {
display: none;
}
.miro-import-textarea {
width: 100%;
padding: 12px;
border: 1px solid var(--color-divider, #ddd);
border-radius: 8px;
font-family: monospace;
font-size: 12px;
resize: vertical;
background: var(--color-background, white);
color: var(--color-text, #333);
}
.miro-import-textarea:focus {
outline: none;
border-color: var(--color-primary, #2563eb);
}
.miro-import-button {
padding: 12px 24px;
background: var(--color-primary, #2563eb);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.miro-import-button:hover:not(:disabled) {
background: var(--color-primary-dark, #1d4ed8);
}
.miro-import-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.miro-import-progress {
margin-top: 20px;
}
.miro-import-progress-bar {
height: 8px;
background: var(--color-background, #f0f0f0);
border-radius: 4px;
overflow: hidden;
}
.miro-import-progress-fill {
height: 100%;
background: var(--color-primary, #2563eb);
transition: width 0.3s ease;
}
.miro-import-progress-text {
margin: 8px 0 0;
font-size: 13px;
color: var(--color-text-1, #666);
text-align: center;
}
.miro-import-error {
margin-top: 16px;
padding: 12px;
background: #fee2e2;
color: #dc2626;
border-radius: 8px;
font-size: 14px;
}
.miro-import-result {
margin-top: 20px;
padding: 16px;
border-radius: 8px;
text-align: center;
}
.miro-import-result.success {
background: #dcfce7;
color: #16a34a;
}
.miro-import-result.failed {
background: #fee2e2;
color: #dc2626;
}
.miro-import-result p {
margin: 0 0 12px;
}
.miro-import-warnings {
font-size: 12px;
opacity: 0.8;
}
`}</style>
</div>
)
}
export default MiroImportDialog

View File

@ -0,0 +1,884 @@
/**
* Miro Integration Modal
*
* Allows users to import Miro boards into their canvas.
* Supports two methods:
* 1. Paste JSON from miro-export CLI tool (recommended for casual use)
* 2. Connect Miro API for direct imports (power users)
*/
import React, { useState, useCallback, useRef } from 'react';
import { useEditor } from 'tldraw';
import { importMiroJson } from '@/lib/miroImport';
import {
getMiroApiKey,
saveMiroApiKey,
removeMiroApiKey,
isMiroApiKeyConfigured,
extractMiroBoardId,
isValidMiroBoardUrl,
} from '@/lib/miroApiKey';
interface MiroIntegrationModalProps {
isOpen: boolean;
onClose: () => void;
username: string;
isDarkMode?: boolean;
}
type Tab = 'import' | 'api-setup' | 'help';
export function MiroIntegrationModal({
isOpen,
onClose,
username,
isDarkMode: _isDarkMode = false,
}: MiroIntegrationModalProps) {
const editor = useEditor();
const fileInputRef = useRef<HTMLInputElement>(null);
const [activeTab, setActiveTab] = useState<Tab>('import');
const [jsonText, setJsonText] = useState('');
const [boardUrl, setBoardUrl] = useState('');
const [apiKeyInput, setApiKeyInput] = useState('');
const [isImporting, setIsImporting] = useState(false);
const [progress, setProgress] = useState({ stage: '', percent: 0 });
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const hasApiKey = isMiroApiKeyConfigured(username);
const resetState = useCallback(() => {
setJsonText('');
setBoardUrl('');
setIsImporting(false);
setProgress({ stage: '', percent: 0 });
setError(null);
setSuccess(null);
}, []);
const handleClose = useCallback(() => {
resetState();
onClose();
}, [onClose, resetState]);
// Import from JSON string
const handleJsonImport = useCallback(async (json: string) => {
setIsImporting(true);
setError(null);
setSuccess(null);
try {
const viewportBounds = editor.getViewportPageBounds();
const offset = {
x: viewportBounds.x + viewportBounds.w / 2,
y: viewportBounds.y + viewportBounds.h / 2,
};
const result = await importMiroJson(
json,
{ migrateAssets: true, offset },
{
onProgress: (stage, percent) => {
setProgress({ stage, percent: Math.round(percent * 100) });
},
}
);
if (result.success && result.shapes.length > 0) {
// Create assets first
for (const asset of result.assets) {
try {
editor.createAssets([asset]);
} catch (e) {
console.warn('Failed to create asset:', e);
}
}
// Create shapes
editor.createShapes(result.shapes);
// Select and zoom to imported shapes
const shapeIds = result.shapes.map((s: any) => s.id);
editor.setSelectedShapes(shapeIds);
editor.zoomToSelection();
setSuccess(`Imported ${result.shapesCreated} shapes${result.assetsUploaded > 0 ? ` and ${result.assetsUploaded} images` : ''}!`);
// Auto-close after success
setTimeout(() => handleClose(), 2000);
} else {
setError(result.errors.join(', ') || 'No shapes found in the import');
}
} catch (e) {
console.error('Import error:', e);
setError(e instanceof Error ? e.message : 'Failed to import Miro board');
} finally {
setIsImporting(false);
}
}, [editor, handleClose]);
// Handle file upload
const handleFileSelect = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
await handleJsonImport(text);
} catch (e) {
setError('Failed to read file');
}
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [handleJsonImport]);
// Handle paste import
const handlePasteImport = useCallback(() => {
if (!jsonText.trim()) {
setError('Please paste Miro JSON data');
return;
}
handleJsonImport(jsonText);
}, [jsonText, handleJsonImport]);
// Save API key
const handleSaveApiKey = useCallback(() => {
if (!apiKeyInput.trim()) {
setError('Please enter your Miro API token');
return;
}
saveMiroApiKey(apiKeyInput.trim(), username);
setApiKeyInput('');
setSuccess('Miro API token saved!');
setTimeout(() => setSuccess(null), 2000);
}, [apiKeyInput, username]);
// Disconnect API
const handleDisconnectApi = useCallback(() => {
removeMiroApiKey(username);
setSuccess('Miro API disconnected');
setTimeout(() => setSuccess(null), 2000);
}, [username]);
if (!isOpen) return null;
return (
<div
className="miro-modal-overlay"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 999999,
isolation: 'isolate',
}}
onClick={(e) => {
if (e.target === e.currentTarget) handleClose();
}}
>
<div
className="miro-modal"
style={{
backgroundColor: 'var(--color-panel, #ffffff)',
borderRadius: '16px',
width: '520px',
maxWidth: '95vw',
maxHeight: '90vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 25px 80px rgba(0, 0, 0, 0.5)',
position: 'relative',
zIndex: 1000000,
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div style={{
padding: '20px 24px',
borderBottom: '1px solid var(--color-panel-contrast)',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '10px',
background: 'linear-gradient(135deg, #FFD02F 0%, #F2CA00 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '20px',
}}>
📋
</div>
<div style={{ flex: 1 }}>
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600, color: 'var(--color-text)' }}>
Import from Miro
</h2>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)' }}>
Bring your Miro boards into the canvas
</p>
</div>
<button
onClick={handleClose}
style={{
background: '#f3f4f6',
border: '2px solid #e5e7eb',
borderRadius: '8px',
fontSize: '18px',
cursor: 'pointer',
color: '#6b7280',
padding: '6px 10px',
fontWeight: 600,
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#e5e7eb';
e.currentTarget.style.borderColor = '#d1d5db';
e.currentTarget.style.color = '#374151';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#f3f4f6';
e.currentTarget.style.borderColor = '#e5e7eb';
e.currentTarget.style.color = '#6b7280';
}}
>
×
</button>
</div>
{/* Tabs */}
<div style={{
display: 'flex',
borderBottom: '1px solid var(--color-panel-contrast)',
padding: '0 16px',
}}>
{[
{ id: 'import', label: 'Import Board' },
{ id: 'api-setup', label: 'API Setup' },
{ id: 'help', label: 'How It Works' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as Tab)}
style={{
padding: '12px 16px',
fontSize: '13px',
fontWeight: 500,
background: 'none',
border: 'none',
borderBottom: activeTab === tab.id ? '2px solid #FFD02F' : '2px solid transparent',
color: activeTab === tab.id ? 'var(--color-text)' : 'var(--color-text-3)',
cursor: 'pointer',
transition: 'all 0.15s',
}}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
<div style={{
padding: '20px 24px',
overflowY: 'auto',
flex: 1,
}}>
{/* Import Tab */}
{activeTab === 'import' && (
<div>
{/* Method 1: JSON Upload */}
<div style={{ marginBottom: '24px' }}>
<h3 style={{
margin: '0 0 8px 0',
fontSize: '14px',
fontWeight: 600,
color: 'var(--color-text)',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<span style={{
width: '20px',
height: '20px',
borderRadius: '50%',
background: '#FFD02F',
color: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
fontWeight: 700,
}}>1</span>
Upload JSON File
</h3>
<p style={{ margin: '0 0 12px 0', fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.5 }}>
Export your board using the miro-export CLI, then upload the JSON file here.
</p>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
style={{
width: '100%',
padding: '16px',
fontSize: '14px',
fontWeight: 600,
borderRadius: '8px',
border: '2px dashed #9ca3af',
background: '#f9fafb',
color: '#374151',
cursor: isImporting ? 'wait' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
if (!isImporting) {
e.currentTarget.style.borderColor = '#FFD02F';
e.currentTarget.style.background = '#fffbeb';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#9ca3af';
e.currentTarget.style.background = '#f9fafb';
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Choose JSON File
</button>
</div>
{/* Divider */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
margin: '20px 0',
}}>
<div style={{ flex: 1, height: '1px', background: 'var(--color-panel-contrast)' }} />
<span style={{ fontSize: '11px', color: 'var(--color-text-3)', textTransform: 'uppercase' }}>or</span>
<div style={{ flex: 1, height: '1px', background: 'var(--color-panel-contrast)' }} />
</div>
{/* Method 2: Paste JSON */}
<div>
<h3 style={{
margin: '0 0 8px 0',
fontSize: '14px',
fontWeight: 600,
color: 'var(--color-text)',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<span style={{
width: '20px',
height: '20px',
borderRadius: '50%',
background: '#FFD02F',
color: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
fontWeight: 700,
}}>2</span>
Paste JSON
</h3>
<textarea
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
placeholder='[{"type":"sticky_note","id":"..."}]'
disabled={isImporting}
style={{
width: '100%',
height: '120px',
padding: '12px',
fontSize: '12px',
fontFamily: 'monospace',
borderRadius: '8px',
border: '2px solid #d1d5db',
background: '#ffffff',
color: '#1f2937',
resize: 'vertical',
marginBottom: '12px',
outline: 'none',
boxSizing: 'border-box',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#FFD02F';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255, 208, 47, 0.2)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#d1d5db';
e.currentTarget.style.boxShadow = 'none';
}}
/>
<button
onClick={handlePasteImport}
disabled={isImporting || !jsonText.trim()}
style={{
width: '100%',
padding: '14px 16px',
fontSize: '14px',
fontWeight: 600,
borderRadius: '8px',
border: jsonText.trim() ? '2px solid #e6b800' : '2px solid #d1d5db',
background: jsonText.trim() ? 'linear-gradient(135deg, #FFD02F 0%, #F2CA00 100%)' : '#f3f4f6',
color: jsonText.trim() ? '#000' : '#9ca3af',
cursor: isImporting || !jsonText.trim() ? 'not-allowed' : 'pointer',
boxShadow: jsonText.trim() ? '0 2px 8px rgba(255, 208, 47, 0.3)' : 'none',
transition: 'all 0.15s ease',
}}
>
{isImporting ? 'Importing...' : 'Import to Canvas'}
</button>
</div>
{/* Progress */}
{isImporting && (
<div style={{ marginTop: '16px' }}>
<div style={{
height: '4px',
background: 'var(--color-muted-2)',
borderRadius: '2px',
overflow: 'hidden',
}}>
<div style={{
height: '100%',
width: `${progress.percent}%`,
background: '#FFD02F',
transition: 'width 0.3s',
}} />
</div>
<p style={{ margin: '8px 0 0', fontSize: '12px', color: 'var(--color-text-3)', textAlign: 'center' }}>
{progress.stage}
</p>
</div>
)}
{/* Error/Success messages */}
{error && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
background: '#fee2e2',
color: '#dc2626',
fontSize: '13px',
}}>
{error}
</div>
)}
{success && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
background: '#dcfce7',
color: '#16a34a',
fontSize: '13px',
}}>
{success}
</div>
)}
</div>
)}
{/* API Setup Tab */}
{activeTab === 'api-setup' && (
<div>
<div style={{
padding: '16px',
borderRadius: '8px',
background: hasApiKey ? 'rgba(34, 197, 94, 0.1)' : 'var(--color-muted-1)',
marginBottom: '20px',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}>
<span style={{ fontSize: '24px' }}>{hasApiKey ? '✅' : '🔑'}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
{hasApiKey ? 'Miro API Connected' : 'Connect Miro API'}
</div>
<div style={{ fontSize: '12px', color: 'var(--color-text-3)' }}>
{hasApiKey
? 'You can import boards directly from Miro'
: 'For power users who want direct board imports'}
</div>
</div>
</div>
{!hasApiKey ? (
<>
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
fontSize: '13px',
fontWeight: 500,
color: 'var(--color-text)',
marginBottom: '8px',
}}>
Miro API Access Token
</label>
<input
type="password"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
placeholder="Enter your Miro access token..."
style={{
width: '100%',
padding: '14px',
fontSize: '14px',
borderRadius: '8px',
border: '2px solid #d1d5db',
background: '#ffffff',
color: '#1f2937',
outline: 'none',
boxSizing: 'border-box',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = '#FFD02F';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(255, 208, 47, 0.2)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#d1d5db';
e.currentTarget.style.boxShadow = 'none';
}}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveApiKey();
}}
/>
</div>
<button
onClick={handleSaveApiKey}
style={{
width: '100%',
padding: '14px 16px',
fontSize: '14px',
fontWeight: 600,
borderRadius: '8px',
border: '2px solid #e6b800',
background: 'linear-gradient(135deg, #FFD02F 0%, #F2CA00 100%)',
color: '#000',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 208, 47, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
Save API Token
</button>
</>
) : (
<button
onClick={handleDisconnectApi}
style={{
width: '100%',
padding: '14px 16px',
fontSize: '14px',
fontWeight: 600,
borderRadius: '8px',
border: '2px solid #fca5a5',
background: '#fee2e2',
color: '#dc2626',
cursor: 'pointer',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#fecaca';
e.currentTarget.style.borderColor = '#f87171';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#fee2e2';
e.currentTarget.style.borderColor = '#fca5a5';
}}
>
Disconnect Miro API
</button>
)}
{/* API Setup Instructions */}
<div style={{
marginTop: '24px',
padding: '16px',
borderRadius: '8px',
background: 'var(--color-muted-1)',
border: '1px solid var(--color-panel-contrast)',
}}>
<h4 style={{ margin: '0 0 12px', fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>
How to get your Miro API Token
</h4>
<ol style={{
margin: 0,
paddingLeft: '20px',
fontSize: '12px',
color: 'var(--color-text-3)',
lineHeight: 1.8,
}}>
<li>Go to <a href="https://miro.com/app/settings/user-profile/apps" target="_blank" rel="noopener noreferrer" style={{ color: '#FFD02F' }}>Miro Developer Settings</a></li>
<li>Click "Create new app"</li>
<li>Give it a name (e.g., "Canvas Import")</li>
<li>Under "Permissions", enable:
<ul style={{ margin: '4px 0', paddingLeft: '16px' }}>
<li>boards:read</li>
<li>boards:write (optional)</li>
</ul>
</li>
<li>Click "Install app and get OAuth token"</li>
<li>Select your team and authorize</li>
<li>Copy the access token and paste it above</li>
</ol>
<p style={{ margin: '12px 0 0', fontSize: '11px', color: 'var(--color-text-3)' }}>
Note: This is a one-time setup. Your token is stored locally and never sent to our servers.
</p>
</div>
{error && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
background: '#fee2e2',
color: '#dc2626',
fontSize: '13px',
}}>
{error}
</div>
)}
{success && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
background: '#dcfce7',
color: '#16a34a',
fontSize: '13px',
}}>
{success}
</div>
)}
</div>
)}
{/* Help Tab */}
{activeTab === 'help' && (
<div>
<div style={{
padding: '16px',
borderRadius: '8px',
background: 'linear-gradient(135deg, rgba(255, 208, 47, 0.1) 0%, rgba(242, 202, 0, 0.1) 100%)',
border: '1px solid rgba(255, 208, 47, 0.3)',
marginBottom: '20px',
}}>
<h3 style={{ margin: '0 0 8px', fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
Quick Start (Recommended)
</h3>
<p style={{ margin: 0, fontSize: '13px', color: 'var(--color-text-3)', lineHeight: 1.6 }}>
The easiest way to import a Miro board is using the <code style={{
background: 'var(--color-muted-2)',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '12px',
}}>miro-export</code> CLI tool. This runs on your computer and exports your board as JSON.
</p>
</div>
<h4 style={{ margin: '0 0 12px', fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
Step-by-Step Instructions
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* Step 1 */}
<div style={{ display: 'flex', gap: '12px' }}>
<div style={{
width: '28px',
height: '28px',
borderRadius: '50%',
background: '#FFD02F',
color: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
fontWeight: 700,
flexShrink: 0,
}}>1</div>
<div>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', marginBottom: '4px' }}>
Find your Miro Board ID
</div>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.5 }}>
Open your board in Miro. The Board ID is in the URL:
</p>
<code style={{
display: 'block',
margin: '8px 0',
padding: '8px 12px',
background: 'var(--color-muted-1)',
borderRadius: '6px',
fontSize: '11px',
color: 'var(--color-text)',
wordBreak: 'break-all',
}}>
miro.com/app/board/<span style={{ background: '#FFD02F', color: '#000', padding: '0 4px', borderRadius: '2px' }}>uXjVLxxxxxxxx=</span>/
</code>
</div>
</div>
{/* Step 2 */}
<div style={{ display: 'flex', gap: '12px' }}>
<div style={{
width: '28px',
height: '28px',
borderRadius: '50%',
background: '#FFD02F',
color: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
fontWeight: 700,
flexShrink: 0,
}}>2</div>
<div>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', marginBottom: '4px' }}>
Run the Export Command
</div>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.5 }}>
Open your terminal and run:
</p>
<code style={{
display: 'block',
margin: '8px 0',
padding: '8px 12px',
background: 'var(--color-muted-1)',
borderRadius: '6px',
fontSize: '11px',
color: 'var(--color-text)',
wordBreak: 'break-all',
}}>
npx miro-export -b YOUR_BOARD_ID -e json -o board.json
</code>
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--color-text-3)' }}>
This will open Miro in a browser window. Sign in if prompted.
</p>
</div>
</div>
{/* Step 3 */}
<div style={{ display: 'flex', gap: '12px' }}>
<div style={{
width: '28px',
height: '28px',
borderRadius: '50%',
background: '#FFD02F',
color: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '13px',
fontWeight: 700,
flexShrink: 0,
}}>3</div>
<div>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)', marginBottom: '4px' }}>
Upload the JSON
</div>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.5 }}>
Go to the "Import Board" tab and upload your <code style={{
background: 'var(--color-muted-2)',
padding: '1px 4px',
borderRadius: '3px',
fontSize: '11px',
}}>board.json</code> file. That's it!
</p>
</div>
</div>
</div>
{/* What Gets Imported */}
<div style={{
marginTop: '24px',
padding: '16px',
borderRadius: '8px',
background: 'var(--color-muted-1)',
}}>
<h4 style={{ margin: '0 0 12px', fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>
What Gets Imported
</h4>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '8px',
fontSize: '12px',
}}>
{[
{ icon: '📝', label: 'Sticky Notes' },
{ icon: '🔷', label: 'Shapes' },
{ icon: '📄', label: 'Text' },
{ icon: '🖼️', label: 'Images' },
{ icon: '🔗', label: 'Connectors' },
{ icon: '🖼️', label: 'Frames' },
{ icon: '🃏', label: 'Cards' },
].map((item) => (
<div key={item.label} style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
color: 'var(--color-text-3)',
}}>
<span style={{ fontSize: '14px' }}>{item.icon}</span>
{item.label}
</div>
))}
</div>
<p style={{
margin: '12px 0 0',
fontSize: '11px',
color: 'var(--color-text-3)',
fontStyle: 'italic',
}}>
Images are automatically downloaded and stored locally, so they'll persist even if you lose Miro access.
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}
export default MiroIntegrationModal;

View File

@ -1,122 +1,479 @@
import React from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useParams } from 'react-router-dom';
import { useDialogs } from 'tldraw';
import { InviteDialog } from '../ui/InviteDialog';
import { QRCodeSVG } from 'qrcode.react';
interface ShareBoardButtonProps {
className?: string;
}
type PermissionType = 'view' | 'edit' | 'admin';
const PERMISSION_LABELS: Record<PermissionType, { label: string; description: string; color: string }> = {
view: { label: 'View', description: 'Can view but not edit', color: '#6b7280' },
edit: { label: 'Edit', description: 'Can view and edit', color: '#3b82f6' },
admin: { label: 'Admin', description: 'Full control', color: '#10b981' },
};
const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) => {
const { slug } = useParams<{ slug: string }>();
const { addDialog, removeDialog } = useDialogs();
const [showDropdown, setShowDropdown] = useState(false);
const [copied, setCopied] = useState(false);
const [permission, setPermission] = useState<PermissionType>('edit');
const [nfcStatus, setNfcStatus] = useState<'idle' | 'writing' | 'success' | 'error' | 'unsupported'>('idle');
const [nfcMessage, setNfcMessage] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
const handleShare = () => {
const boardSlug = slug || 'mycofi33';
const boardUrl = `${window.location.origin}/board/${boardSlug}`;
const boardSlug = slug || 'mycofi33';
const boardUrl = `${window.location.origin}/board/${boardSlug}`;
addDialog({
id: "invite-dialog",
component: ({ onClose }: { onClose: () => void }) => (
<InviteDialog
onClose={() => {
onClose();
removeDialog("invite-dialog");
}}
boardUrl={boardUrl}
boardSlug={boardSlug}
/>
),
});
// Update dropdown position when it opens
useEffect(() => {
if (showDropdown && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
});
}
}, [showDropdown]);
// Generate URL with permission parameter
const getShareUrl = () => {
const url = new URL(boardUrl);
url.searchParams.set('access', permission);
return url.toString();
};
// Check NFC support on mount
useEffect(() => {
if (!('NDEFReader' in window)) {
setNfcStatus('unsupported');
}
}, []);
// Close dropdown when clicking outside or pressing ESC
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setShowDropdown(false);
}
};
if (showDropdown) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown, true);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown, true);
};
}, [showDropdown]);
const handleCopyUrl = async () => {
try {
await navigator.clipboard.writeText(getShareUrl());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('Failed to copy URL:', err);
}
};
const handleNfcWrite = async () => {
if (!('NDEFReader' in window)) {
setNfcStatus('unsupported');
setNfcMessage('NFC is not supported on this device');
return;
}
try {
setNfcStatus('writing');
setNfcMessage('Hold your NFC tag near the device...');
const ndef = new (window as any).NDEFReader();
await ndef.write({
records: [
{ recordType: "url", data: getShareUrl() }
]
});
setNfcStatus('success');
setNfcMessage('Board URL written to NFC tag!');
setTimeout(() => {
setNfcStatus('idle');
setNfcMessage('');
}, 3000);
} catch (err: any) {
console.error('NFC write error:', err);
setNfcStatus('error');
if (err.name === 'NotAllowedError') {
setNfcMessage('NFC permission denied. Please allow NFC access.');
} else if (err.name === 'NotSupportedError') {
setNfcMessage('NFC is not supported on this device');
} else {
setNfcMessage(`Failed to write NFC tag: ${err.message || 'Unknown error'}`);
}
}
};
// Detect if we're in share-panel (compact) vs toolbar (full button)
const isCompact = className.includes('share-panel-btn');
if (isCompact) {
// Icon-only version for the top-right share panel
// Icon-only version for the top-right share panel with dropdown
return (
<button
onClick={handleShare}
className={`share-board-button ${className}`}
title="Invite others to this board"
style={{
background: 'none',
border: 'none',
padding: '6px',
cursor: 'pointer',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-1)',
opacity: 0.7,
transition: 'opacity 0.15s, background 0.15s',
pointerEvents: 'all',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1';
e.currentTarget.style.background = 'var(--color-muted-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.7';
e.currentTarget.style.background = 'none';
}}
>
{/* User with plus icon (invite/add person) */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{/* User outline */}
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
{/* Plus sign */}
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="16" y1="11" x2="22" y2="11" />
</svg>
</button>
<div ref={dropdownRef} style={{ pointerEvents: 'all' }}>
<button
ref={triggerRef}
onClick={() => setShowDropdown(!showDropdown)}
className={`share-board-button ${className}`}
title="Invite others to this board"
style={{
background: showDropdown ? 'var(--color-muted-2)' : 'none',
border: 'none',
padding: '6px',
cursor: 'pointer',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-1)',
opacity: showDropdown ? 1 : 0.7,
transition: 'opacity 0.15s, background 0.15s',
pointerEvents: 'all',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1';
e.currentTarget.style.background = 'var(--color-muted-2)';
}}
onMouseLeave={(e) => {
if (!showDropdown) {
e.currentTarget.style.opacity = '0.7';
e.currentTarget.style.background = 'none';
}
}}
>
{/* User with plus icon (invite/add person) */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{/* User outline */}
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
{/* Plus sign */}
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="16" y1="11" x2="22" y2="11" />
</svg>
</button>
{/* Dropdown - rendered via portal to break out of parent container */}
{showDropdown && dropdownPosition && createPortal(
<div
style={{
position: 'fixed',
top: dropdownPosition.top,
right: dropdownPosition.right,
width: '320px',
background: 'var(--color-panel)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
zIndex: 100000,
overflow: 'hidden',
pointerEvents: 'all',
}}
onWheel={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div style={{
padding: '12px 16px',
borderBottom: '1px solid var(--color-panel-contrast)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
Invite to Board
</span>
<button
onClick={() => setShowDropdown(false)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px',
color: 'var(--color-text-3)',
fontSize: '18px',
lineHeight: 1,
}}
>
x
</button>
</div>
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* Board name */}
<div style={{
textAlign: 'center',
padding: '6px 10px',
backgroundColor: 'var(--color-muted-2)',
borderRadius: '6px',
}}>
<span style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>Board: </span>
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>{boardSlug}</span>
</div>
{/* Permission selector */}
<div>
<span style={{ fontSize: '11px', color: 'var(--color-text-3)', fontWeight: 500, marginBottom: '6px', display: 'block' }}>Access Level</span>
<div style={{ display: 'flex', gap: '6px' }}>
{(['view', 'edit', 'admin'] as PermissionType[]).map((perm) => {
const isActive = permission === perm;
const { label, color } = PERMISSION_LABELS[perm];
return (
<button
key={perm}
onClick={() => setPermission(perm)}
style={{
flex: 1,
padding: '8px 6px',
border: isActive ? `2px solid ${color}` : '2px solid var(--color-panel-contrast)',
background: isActive ? `${color}15` : 'var(--color-panel)',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: isActive ? 600 : 500,
color: isActive ? color : 'var(--color-text)',
transition: 'all 0.15s ease',
}}
>
{label}
</button>
);
})}
</div>
</div>
{/* QR Code and URL */}
<div style={{
display: 'flex',
gap: '12px',
padding: '12px',
backgroundColor: 'var(--color-muted-2)',
borderRadius: '8px',
}}>
{/* QR Code */}
<div style={{
padding: '8px',
backgroundColor: 'white',
borderRadius: '6px',
flexShrink: 0,
}}>
<QRCodeSVG
value={getShareUrl()}
size={80}
level="M"
includeMargin={false}
/>
</div>
{/* URL and Copy */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '8px' }}>
<div style={{
padding: '8px',
backgroundColor: 'var(--color-panel)',
borderRadius: '4px',
border: '1px solid var(--color-panel-contrast)',
wordBreak: 'break-all',
fontSize: '10px',
fontFamily: 'monospace',
color: 'var(--color-text)',
maxHeight: '40px',
overflowY: 'auto',
}}>
{getShareUrl()}
</div>
<button
onClick={handleCopyUrl}
style={{
padding: '6px 12px',
backgroundColor: copied ? '#10b981' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
transition: 'background 0.15s',
}}
>
{copied ? 'Copied!' : 'Copy Link'}
</button>
</div>
</div>
{/* Advanced options (collapsible) */}
<div>
<button
onClick={() => setShowAdvanced(!showAdvanced)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '11px',
color: 'var(--color-text-3)',
padding: '4px 0',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<svg
width="10"
height="10"
viewBox="0 0 16 16"
fill="currentColor"
style={{ transform: showAdvanced ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
>
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
</svg>
More options (NFC, Audio)
</button>
{showAdvanced && (
<div style={{ marginTop: '8px', display: 'flex', gap: '8px' }}>
{/* NFC Button */}
<button
onClick={handleNfcWrite}
disabled={nfcStatus === 'unsupported' || nfcStatus === 'writing'}
style={{
flex: 1,
padding: '10px',
backgroundColor: nfcStatus === 'unsupported' ? 'var(--color-muted-2)' :
nfcStatus === 'success' ? '#d1fae5' :
nfcStatus === 'error' ? '#fee2e2' :
nfcStatus === 'writing' ? '#e0e7ff' : 'var(--color-panel)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '6px',
cursor: nfcStatus === 'unsupported' || nfcStatus === 'writing' ? 'not-allowed' : 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
opacity: nfcStatus === 'unsupported' ? 0.5 : 1,
}}
>
<span style={{ fontSize: '16px' }}>
{nfcStatus === 'success' ? '✓' : nfcStatus === 'error' ? '!' : '📡'}
</span>
<span style={{ fontSize: '10px', color: 'var(--color-text)', fontWeight: 500 }}>
{nfcStatus === 'writing' ? 'Writing...' :
nfcStatus === 'success' ? 'Written!' :
nfcStatus === 'unsupported' ? 'NFC N/A' :
'NFC Tag'}
</span>
</button>
{/* Audio Button (coming soon) */}
<button
disabled
style={{
flex: 1,
padding: '10px',
backgroundColor: 'var(--color-muted-2)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '6px',
cursor: 'not-allowed',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
opacity: 0.5,
}}
>
<span style={{ fontSize: '16px' }}>🔊</span>
<span style={{ fontSize: '10px', color: 'var(--color-text)', fontWeight: 500 }}>
Audio (Soon)
</span>
</button>
</div>
)}
{nfcMessage && (
<p style={{
marginTop: '6px',
fontSize: '10px',
color: nfcStatus === 'error' ? '#ef4444' :
nfcStatus === 'success' ? '#10b981' : 'var(--color-text-3)',
textAlign: 'center',
}}>
{nfcMessage}
</p>
)}
</div>
</div>
</div>,
document.body
)}
</div>
);
}
// Full button version for other contexts (toolbar, etc.)
return (
<button
onClick={handleShare}
className={`share-board-button ${className}`}
title="Invite others to this board"
style={{
padding: "4px 8px",
borderRadius: "4px",
background: "#3b82f6",
color: "white",
border: "none",
cursor: "pointer",
fontWeight: 500,
transition: "background 0.2s ease",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
whiteSpace: "nowrap",
userSelect: "none",
display: "flex",
alignItems: "center",
gap: "4px",
height: "22px",
minHeight: "22px",
boxSizing: "border-box",
fontSize: "0.75rem",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#2563eb";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "#3b82f6";
}}
>
{/* User with plus icon (invite/add person) */}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="16" y1="11" x2="22" y2="11" />
</svg>
</button>
<div ref={dropdownRef} style={{ position: 'relative' }}>
<button
onClick={() => setShowDropdown(!showDropdown)}
className={`share-board-button ${className}`}
title="Invite others to this board"
style={{
padding: "4px 8px",
borderRadius: "4px",
background: "#3b82f6",
color: "white",
border: "none",
cursor: "pointer",
fontWeight: 500,
transition: "background 0.2s ease",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
whiteSpace: "nowrap",
userSelect: "none",
display: "flex",
alignItems: "center",
gap: "4px",
height: "22px",
minHeight: "22px",
boxSizing: "border-box",
fontSize: "0.75rem",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#2563eb";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "#3b82f6";
}}
>
{/* User with plus icon (invite/add person) */}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="16" y1="11" x2="22" y2="11" />
</svg>
</button>
</div>
);
};

View File

@ -41,7 +41,7 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
const handleStarToggle = async () => {
if (!session.authed || !session.username || !slug) {
addNotification('Please log in to star boards', 'warning');
showPopupMessage('Please log in to star boards', 'error');
return;
}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../context/AuthContext';
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import CryptID from './CryptID';
import '../../css/anonymous-banner.css';
@ -13,28 +13,27 @@ interface AnonymousViewerBannerProps {
/**
* Banner shown to anonymous (unauthenticated) users viewing a board.
* Explains CryptID and provides a smooth sign-up flow.
*
* Note: This component should only be rendered when user is NOT authenticated.
* The parent component (Board.tsx) handles the auth check via:
* {(!session.authed || showEditPrompt) && <AnonymousViewerBanner ... />}
*/
const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
onAuthenticated,
triggeredByEdit = false
}) => {
const { session } = useAuth();
const [isDismissed, setIsDismissed] = useState(false);
const [showSignUp, setShowSignUp] = useState(false);
const [isExpanded, setIsExpanded] = useState(triggeredByEdit);
// Check if banner was previously dismissed this session
useEffect(() => {
const dismissed = sessionStorage.getItem('anonymousBannerDismissed');
if (dismissed && !triggeredByEdit) {
setIsDismissed(true);
}
}, [triggeredByEdit]);
// If user is authenticated, don't show banner
if (session.authed) {
return null;
}
// Note: We intentionally do NOT persist banner dismissal across page loads.
// The banner should appear on each new page load for anonymous users
// to remind them about CryptID. Only dismiss within the current component lifecycle.
//
// Previous implementation used sessionStorage to remember dismissal, but this caused
// issues where users who dismissed once would never see it again until they closed
// their browser entirely - even if they logged out or their session expired.
//
// If triggeredByEdit is true, always show regardless of dismiss state.
// If dismissed and not triggered by edit, don't show
if (isDismissed && !triggeredByEdit) {
@ -42,7 +41,8 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
}
const handleDismiss = () => {
sessionStorage.setItem('anonymousBannerDismissed', 'true');
// Just set local state - don't persist to sessionStorage
// This allows the banner to show again on page refresh
setIsDismissed(true);
};
@ -52,6 +52,9 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
const handleSignUpSuccess = () => {
setShowSignUp(false);
// Dismiss the banner when user signs in successfully
// No need to persist - the parent condition (!session.authed) will hide us
setIsDismissed(true);
if (onAuthenticated) {
onAuthenticated();
}
@ -61,107 +64,134 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
setShowSignUp(false);
};
// Show CryptID modal when sign up is clicked
if (showSignUp) {
return (
<div className="anonymous-banner-modal-overlay">
<div className="anonymous-banner-modal">
<CryptID
onSuccess={handleSignUpSuccess}
onCancel={handleSignUpCancel}
/>
</div>
</div>
);
}
return (
<div className={`anonymous-viewer-banner ${triggeredByEdit ? 'edit-triggered' : ''} ${isExpanded ? 'expanded' : ''}`}>
<div className="banner-content">
<div className="banner-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" fill="currentColor"/>
<div className={`anonymous-viewer-banner ${triggeredByEdit ? 'edit-triggered' : ''}`}>
{/* Dismiss button in top-right corner */}
{!triggeredByEdit && (
<button
className="banner-dismiss-btn"
onClick={handleDismiss}
title="Dismiss"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" fill="currentColor"/>
</svg>
</div>
</button>
)}
<div className="banner-text">
{triggeredByEdit ? (
<p className="banner-headline">
<strong>Want to edit this board?</strong>
</p>
) : (
<p className="banner-headline">
<strong>You're viewing this board anonymously</strong>
</p>
)}
<div className="banner-content">
<div className="banner-header">
<div className="banner-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" fill="currentColor"/>
</svg>
</div>
{isExpanded ? (
<div className="banner-details">
<p>
Sign in by creating a username as your <strong>CryptID</strong> &mdash; no password required!
<div className="banner-text">
{triggeredByEdit ? (
<p className="banner-headline">
<strong>Sign in to edit</strong>
</p>
<ul className="cryptid-benefits">
<li>
<span className="benefit-icon">&#x1F512;</span>
<span>Secured with encrypted keys, right in your browser, by a <a href="https://www.w3.org/TR/WebCryptoAPI/" target="_blank" rel="noopener noreferrer">W3C standard</a> algorithm</span>
</li>
<li>
<span className="benefit-icon">&#x1F4BE;</span>
<span>Your session is stored for offline access, encrypted in browser storage by the same key</span>
</li>
<li>
<span className="benefit-icon">&#x1F4E6;</span>
<span>Full data portability &mdash; use your canvas securely any time you like</span>
</li>
</ul>
</div>
) : (
) : (
<p className="banner-headline">
<strong>Viewing anonymously</strong>
</p>
)}
<p className="banner-summary">
Create a free CryptID to edit this board &mdash; no password needed!
Sign in with CryptID to edit
</p>
)}
</div>
</div>
{/* Action button */}
<div className="banner-actions">
<button
className="banner-signup-btn"
onClick={handleSignUpClick}
>
Create CryptID
Sign in
</button>
{!triggeredByEdit && (
<button
className="banner-dismiss-btn"
onClick={handleDismiss}
title="Dismiss"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" fill="currentColor"/>
</svg>
</button>
)}
{!isExpanded && (
<button
className="banner-expand-btn"
onClick={() => setIsExpanded(true)}
title="Learn more"
>
Learn more
</button>
)}
</div>
</div>
{triggeredByEdit && (
<div className="banner-edit-notice">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" fill="currentColor"/>
</svg>
<span>This board is in read-only mode for anonymous viewers</span>
<span>Read-only for anonymous viewers</span>
</div>
)}
{/* CryptID Sign In Modal - same as CryptIDDropdown */}
{showSignUp && createPortal(
<div
className="cryptid-modal-overlay"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 999999,
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
handleSignUpCancel();
}
}}
>
<div
className="cryptid-modal"
style={{
backgroundColor: 'var(--color-panel, #ffffff)',
borderRadius: '16px',
padding: '0',
maxWidth: '580px',
width: '95vw',
maxHeight: '90vh',
boxShadow: '0 25px 80px rgba(0, 0, 0, 0.4)',
overflow: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<button
onClick={handleSignUpCancel}
style={{
position: 'absolute',
top: '12px',
right: '12px',
background: 'var(--color-muted-2, #f3f4f6)',
border: 'none',
borderRadius: '50%',
width: '28px',
height: '28px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-2, #6b7280)',
fontSize: '16px',
zIndex: 1,
}}
>
×
</button>
<CryptID
onSuccess={handleSignUpSuccess}
onCancel={handleSignUpCancel}
/>
</div>
</div>,
document.body
)}
</div>
);
};

View File

@ -4,6 +4,7 @@ import { useAuth } from '../../context/AuthContext';
import { useNotifications } from '../../context/NotificationContext';
import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser';
import { WORKER_URL } from '../../constants/workerUrl';
import '../../css/crypto-auth.css'; // For spin animation
interface CryptIDProps {
onSuccess?: () => void;
@ -26,6 +27,8 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
const [existingUsers, setExistingUsers] = useState<string[]>([]);
const [suggestedUsername, setSuggestedUsername] = useState<string>('');
const [emailSent, setEmailSent] = useState(false);
const [usernameAvailable, setUsernameAvailable] = useState<boolean | null>(null);
const [checkingUsername, setCheckingUsername] = useState(false);
const [browserSupport, setBrowserSupport] = useState<{
supported: boolean;
secure: boolean;
@ -97,6 +100,45 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
checkExistingUsers();
}, [addNotification]);
// Check username availability with debounce
useEffect(() => {
// Only check when registering and on username step
if (!isRegistering || registrationStep !== 'username') {
return;
}
// Reset availability when username changes
setUsernameAvailable(null);
setError(null);
// Don't check if username is too short
if (username.length < 3) {
return;
}
// Debounce the check
const timeoutId = setTimeout(async () => {
setCheckingUsername(true);
try {
const response = await fetch(`${WORKER_URL}/api/auth/check-username?username=${encodeURIComponent(username)}`);
const data = await response.json() as { available: boolean; error?: string };
setUsernameAvailable(data.available);
if (!data.available && data.error) {
setError(data.error);
}
} catch (err) {
console.error('Error checking username:', err);
// On network error, allow proceeding (server will validate on registration)
setUsernameAvailable(null);
} finally {
setCheckingUsername(false);
}
}, 500); // Wait 500ms after user stops typing
return () => clearTimeout(timeoutId);
}, [username, isRegistering, registrationStep]);
/**
* Send backup email with magic link
*/
@ -160,8 +202,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
/**
* Handle login
*/
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
const handleLogin = async () => {
setError(null);
setIsLoading(true);
@ -240,20 +281,34 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
<div style={styles.explainerItem}>
<span style={styles.explainerIcon}>🔑</span>
<div>
<strong>Cryptographic Keys</strong>
<strong>No Password Needed</strong>
<p style={styles.explainerText}>
When you create an account, your browser generates a unique cryptographic key pair.
The private key never leaves your device.
Encrypted keys are created directly on your device using the{' '}
<a href="https://w3c.github.io/webcrypto/" target="_blank" rel="noopener noreferrer" style={{ color: '#8b5cf6' }}>
W3C Web Cryptography API
</a>{' '}
standard. Your identity and data are secured locally - no passwords to remember or leak.
</p>
</div>
</div>
<div style={styles.explainerItem}>
<span style={styles.explainerIcon}>💾</span>
<div>
<strong>Secure Storage</strong>
<strong>Secure Browser Storage</strong>
<p style={styles.explainerText}>
Your keys are stored securely in your browser using WebCryptoAPI -
the same technology used by banks and governments.
Your cryptographic keys encrypt your data locally using local-first architecture.
This means you control what you share - your data sovereignty is protected by default
for individuals and groups alike.
</p>
</div>
</div>
<div style={styles.explainerItem}>
<span style={styles.explainerIcon}>📧</span>
<div>
<strong>Link Your Email</strong>
<p style={styles.explainerText}>
Add an email address to connect to your account from other devices.
We'll send you a secure link to establish trust between devices.
</p>
</div>
</div>
@ -262,8 +317,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
<div>
<strong>Multi-Device Access</strong>
<p style={styles.explainerText}>
Add your email to receive a backup link. Open it on another device
(like your phone) to sync your account securely.
Add a mobile device or tablet and link keys for one streamlined identity across all your devices.
</p>
</div>
</div>
@ -272,14 +326,14 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
<div style={styles.featureList}>
<div style={styles.featureItem}>
<span style={{ color: '#22c55e' }}></span> No password to remember or lose
<span style={{ color: '#22c55e' }}></span> Built on W3C cryptography standards
</div>
<div style={styles.featureItem}>
<span style={{ color: '#22c55e' }}></span> Local-first data sovereignty
</div>
<div style={styles.featureItem}>
<span style={{ color: '#22c55e' }}></span> Phishing-resistant authentication
</div>
<div style={styles.featureItem}>
<span style={{ color: '#22c55e' }}></span> Your data stays encrypted
</div>
</div>
<button
@ -296,7 +350,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
setUsername(existingUsers[0]);
}
}}
style={styles.linkButton}
style={{ ...styles.linkButton, marginTop: '20px' }}
>
Already have an account? Sign in
</button>
@ -310,24 +364,82 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
<h2 style={styles.title}>Choose Your Username</h2>
<p style={styles.subtitle}>This is your unique identity on the platform</p>
<form onSubmit={(e) => { e.preventDefault(); setRegistrationStep('email'); }}>
<form onSubmit={(e) => {
e.preventDefault();
if (usernameAvailable !== false && !checkingUsername) {
setRegistrationStep('email');
}
}}>
<div style={styles.inputGroup}>
<label style={styles.label}>Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
placeholder="e.g., alex_smith"
style={styles.input}
required
minLength={3}
maxLength={20}
autoFocus
/>
<p style={styles.hint}>3-20 characters, lowercase letters, numbers, _ and -</p>
<div style={{ position: 'relative' }}>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
placeholder="e.g., alex_smith"
style={{
...styles.input,
paddingRight: '40px',
borderColor: username.length >= 3
? (usernameAvailable === true ? '#22c55e'
: usernameAvailable === false ? '#ef4444'
: undefined)
: undefined,
}}
required
minLength={3}
maxLength={20}
autoFocus
/>
{/* Availability indicator */}
{username.length >= 3 && (
<div style={{
position: 'absolute',
right: '12px',
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{checkingUsername ? (
<div style={{
width: '18px',
height: '18px',
border: '2px solid #a0a0b0',
borderTopColor: 'transparent',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}} />
) : usernameAvailable === true ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="3">
<polyline points="20 6 9 17 4 12" />
</svg>
) : usernameAvailable === false ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="3">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
) : null}
</div>
)}
</div>
<p style={{
...styles.hint,
color: usernameAvailable === true ? '#22c55e'
: usernameAvailable === false ? '#ef4444'
: undefined,
}}>
{usernameAvailable === true
? 'Username is available!'
: usernameAvailable === false
? 'Username is already taken'
: '3-20 characters, lowercase letters, numbers, _ and -'}
</p>
</div>
{error && <div style={styles.error}>{error}</div>}
{error && !usernameAvailable && <div style={styles.error}>{error}</div>}
<div style={styles.buttonGroup}>
<button
@ -339,14 +451,14 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
</button>
<button
type="submit"
disabled={username.length < 3}
disabled={username.length < 3 || usernameAvailable === false || checkingUsername}
style={{
...styles.primaryButton,
opacity: username.length < 3 ? 0.5 : 1,
cursor: username.length < 3 ? 'not-allowed' : 'pointer',
opacity: (username.length < 3 || usernameAvailable === false || checkingUsername) ? 0.5 : 1,
cursor: (username.length < 3 || usernameAvailable === false || checkingUsername) ? 'not-allowed' : 'pointer',
}}
>
Continue
{checkingUsername ? 'Checking...' : 'Continue'}
</button>
</div>
</form>
@ -457,11 +569,6 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
</div>
)}
{onCancel && registrationStep !== 'success' && (
<button onClick={onCancel} style={styles.cancelButton}>
Cancel
</button>
)}
</div>
);
}
@ -473,59 +580,55 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
<div style={styles.iconLarge}>🔐</div>
<h2 style={styles.title}>Sign In with CryptID</h2>
{existingUsers.length > 0 && (
<div style={styles.existingUsers}>
<p style={styles.existingUsersLabel}>Your accounts on this device:</p>
<div style={styles.userList}>
{existingUsers.map((user) => (
<button
key={user}
onClick={() => setUsername(user)}
style={{
...styles.userButton,
borderColor: username === user ? '#8b5cf6' : '#e5e7eb',
backgroundColor: username === user ? 'rgba(139, 92, 246, 0.1)' : 'transparent',
}}
disabled={isLoading}
>
<span style={styles.userIcon}>🔑</span>
<span style={styles.userName}>{user}</span>
{username === user && <span style={styles.selectedBadge}>Selected</span>}
</button>
))}
{existingUsers.length > 0 ? (
<>
<div style={styles.existingUsers}>
<p style={styles.existingUsersLabel}>Select your account:</p>
<div style={styles.userList}>
{existingUsers.map((user) => (
<button
key={user}
onClick={() => setUsername(user)}
style={{
...styles.userButton,
borderColor: username === user ? '#8b5cf6' : '#e5e7eb',
backgroundColor: username === user ? 'rgba(139, 92, 246, 0.1)' : 'transparent',
}}
disabled={isLoading}
>
<span style={styles.userIcon}>🔑</span>
<span style={styles.userName}>{user}</span>
{username === user && <span style={styles.selectedBadge}>Selected</span>}
</button>
))}
</div>
</div>
{error && <div style={styles.error}>{error}</div>}
<button
onClick={handleLogin}
disabled={isLoading || !username.trim()}
style={{
...styles.primaryButton,
opacity: (isLoading || !username.trim()) ? 0.5 : 1,
cursor: (isLoading || !username.trim()) ? 'not-allowed' : 'pointer',
}}
>
{isLoading ? 'Signing In...' : 'Sign In'}
</button>
</>
) : (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<p style={{ ...styles.subtitle, marginBottom: '16px' }}>
No accounts found on this device.
</p>
<p style={{ ...styles.hint, marginBottom: '20px' }}>
Create a new CryptID or use a backup link from another device to sign in here.
</p>
</div>
)}
<form onSubmit={handleLogin}>
<div style={styles.inputGroup}>
<label style={styles.label}>Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter your username"
style={styles.input}
required
disabled={isLoading}
/>
</div>
{error && <div style={styles.error}>{error}</div>}
<button
type="submit"
disabled={isLoading || !username.trim()}
style={{
...styles.primaryButton,
opacity: (isLoading || !username.trim()) ? 0.5 : 1,
cursor: (isLoading || !username.trim()) ? 'not-allowed' : 'pointer',
}}
>
{isLoading ? 'Signing In...' : 'Sign In'}
</button>
</form>
<button
onClick={() => {
setIsRegistering(true);
@ -533,189 +636,182 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
setUsername('');
setError(null);
}}
style={styles.linkButton}
style={existingUsers.length > 0 ? { ...styles.linkButton, marginTop: '20px' } : styles.primaryButton}
disabled={isLoading}
>
Need an account? Create one
{existingUsers.length > 0 ? 'Need an account? Create one' : 'Create a CryptID'}
</button>
{onCancel && (
<button onClick={onCancel} style={styles.cancelButton}>
Cancel
</button>
)}
</div>
</div>
);
};
// Styles
// Styles - compact layout to fit on one screen (updated 2025-12-12)
const styles: Record<string, React.CSSProperties> = {
container: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '20px',
maxWidth: '440px',
padding: '16px',
maxWidth: '540px',
margin: '0 auto',
},
card: {
width: '100%',
backgroundColor: 'var(--color-panel, #fff)',
borderRadius: '16px',
padding: '32px',
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.1)',
padding: '20px',
textAlign: 'center',
},
errorCard: {
width: '100%',
backgroundColor: '#fef2f2',
borderRadius: '16px',
padding: '32px',
padding: '20px',
textAlign: 'center',
},
stepIndicator: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
marginBottom: '16px',
gap: '0',
},
stepDot: {
width: '28px',
height: '28px',
width: '24px',
height: '24px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontSize: '11px',
fontWeight: 600,
color: 'white',
},
stepLine: {
width: '40px',
width: '32px',
height: '2px',
backgroundColor: '#e5e7eb',
},
iconLarge: {
fontSize: '48px',
marginBottom: '16px',
fontSize: '36px',
marginBottom: '12px',
},
errorIcon: {
fontSize: '48px',
marginBottom: '16px',
fontSize: '36px',
marginBottom: '12px',
},
successIcon: {
width: '64px',
height: '64px',
width: '48px',
height: '48px',
borderRadius: '50%',
backgroundColor: '#22c55e',
color: 'white',
fontSize: '32px',
fontSize: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 16px',
margin: '0 auto 12px',
},
title: {
fontSize: '24px',
fontSize: '20px',
fontWeight: 700,
color: 'var(--color-text, #1f2937)',
marginBottom: '8px',
margin: '0 0 8px 0',
marginBottom: '4px',
margin: '0 0 4px 0',
},
subtitle: {
fontSize: '14px',
fontSize: '13px',
color: 'var(--color-text-3, #6b7280)',
marginBottom: '24px',
margin: '0 0 24px 0',
marginBottom: '16px',
margin: '0 0 16px 0',
},
description: {
fontSize: '14px',
fontSize: '13px',
color: '#6b7280',
lineHeight: 1.6,
marginBottom: '24px',
lineHeight: 1.5,
marginBottom: '16px',
},
explainerBox: {
backgroundColor: 'var(--color-muted-2, #f9fafb)',
borderRadius: '12px',
padding: '20px',
marginBottom: '24px',
borderRadius: '10px',
padding: '14px',
marginBottom: '16px',
textAlign: 'left',
},
explainerTitle: {
fontSize: '14px',
fontSize: '13px',
fontWeight: 600,
color: 'var(--color-text, #1f2937)',
marginBottom: '16px',
margin: '0 0 16px 0',
marginBottom: '12px',
margin: '0 0 12px 0',
},
explainerContent: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
gap: '10px',
},
explainerItem: {
display: 'flex',
gap: '12px',
gap: '10px',
alignItems: 'flex-start',
},
explainerIcon: {
fontSize: '20px',
fontSize: '16px',
flexShrink: 0,
},
explainerText: {
fontSize: '12px',
fontSize: '11px',
color: 'var(--color-text-3, #6b7280)',
margin: '4px 0 0 0',
lineHeight: 1.5,
margin: '2px 0 0 0',
lineHeight: 1.4,
},
featureList: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
marginBottom: '24px',
gap: '6px',
marginBottom: '16px',
},
featureItem: {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '13px',
gap: '6px',
fontSize: '12px',
color: 'var(--color-text, #374151)',
},
infoBox: {
display: 'flex',
gap: '12px',
padding: '16px',
gap: '10px',
padding: '12px',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
borderRadius: '10px',
marginBottom: '20px',
borderRadius: '8px',
marginBottom: '14px',
textAlign: 'left',
},
infoIcon: {
fontSize: '20px',
fontSize: '16px',
flexShrink: 0,
},
infoText: {
fontSize: '13px',
fontSize: '12px',
color: 'var(--color-text, #374151)',
margin: 0,
lineHeight: 1.5,
lineHeight: 1.4,
},
successBox: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
padding: '16px',
gap: '8px',
padding: '12px',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
borderRadius: '10px',
marginBottom: '20px',
borderRadius: '8px',
marginBottom: '14px',
},
successItem: {
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '14px',
gap: '8px',
fontSize: '12px',
color: 'var(--color-text, #374151)',
},
successCheck: {
@ -723,22 +819,22 @@ const styles: Record<string, React.CSSProperties> = {
fontWeight: 600,
},
inputGroup: {
marginBottom: '20px',
marginBottom: '14px',
textAlign: 'left',
},
label: {
display: 'block',
fontSize: '13px',
fontSize: '12px',
fontWeight: 500,
color: 'var(--color-text, #374151)',
marginBottom: '6px',
marginBottom: '4px',
},
input: {
width: '100%',
padding: '12px 14px',
fontSize: '15px',
padding: '10px 12px',
fontSize: '14px',
border: '2px solid var(--color-panel-contrast, #e5e7eb)',
borderRadius: '10px',
borderRadius: '8px',
backgroundColor: 'var(--color-panel, #fff)',
color: 'var(--color-text, #1f2937)',
outline: 'none',
@ -746,88 +842,79 @@ const styles: Record<string, React.CSSProperties> = {
boxSizing: 'border-box',
},
hint: {
fontSize: '11px',
fontSize: '10px',
color: 'var(--color-text-3, #9ca3af)',
marginTop: '6px',
marginTop: '4px',
margin: '6px 0 0 0',
},
error: {
padding: '12px',
padding: '10px',
backgroundColor: '#fef2f2',
color: '#dc2626',
borderRadius: '8px',
fontSize: '13px',
marginBottom: '16px',
borderRadius: '6px',
fontSize: '12px',
marginBottom: '12px',
},
buttonGroup: {
display: 'flex',
gap: '12px',
gap: '10px',
},
primaryButton: {
flex: 1,
padding: '14px 24px',
fontSize: '15px',
padding: '10px 18px',
fontSize: '14px',
fontWeight: 600,
color: 'white',
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
border: 'none',
borderRadius: '10px',
borderRadius: '8px',
cursor: 'pointer',
transition: 'transform 0.15s, box-shadow 0.15s',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)',
},
secondaryButton: {
flex: 1,
padding: '14px 24px',
fontSize: '15px',
padding: '10px 18px',
fontSize: '14px',
fontWeight: 500,
color: 'var(--color-text, #374151)',
backgroundColor: 'var(--color-muted-2, #f3f4f6)',
border: 'none',
borderRadius: '10px',
borderRadius: '8px',
cursor: 'pointer',
},
linkButton: {
marginTop: '16px',
padding: '8px',
fontSize: '13px',
marginTop: '12px',
padding: '6px',
fontSize: '12px',
color: '#8b5cf6',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
textDecoration: 'underline',
},
cancelButton: {
marginTop: '16px',
padding: '8px 16px',
fontSize: '13px',
color: 'var(--color-text-3, #6b7280)',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
},
existingUsers: {
marginBottom: '20px',
marginBottom: '14px',
textAlign: 'left',
},
existingUsersLabel: {
fontSize: '13px',
fontSize: '12px',
color: 'var(--color-text-3, #6b7280)',
marginBottom: '10px',
margin: '0 0 10px 0',
marginBottom: '8px',
margin: '0 0 8px 0',
},
userList: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
gap: '6px',
},
userButton: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '12px 14px',
gap: '8px',
padding: '10px 12px',
border: '2px solid #e5e7eb',
borderRadius: '10px',
borderRadius: '8px',
backgroundColor: 'transparent',
cursor: 'pointer',
transition: 'all 0.15s',
@ -835,20 +922,20 @@ const styles: Record<string, React.CSSProperties> = {
textAlign: 'left',
},
userIcon: {
fontSize: '18px',
fontSize: '16px',
},
userName: {
flex: 1,
fontSize: '14px',
fontSize: '13px',
fontWeight: 500,
color: 'var(--color-text, #374151)',
},
selectedBadge: {
fontSize: '11px',
padding: '2px 8px',
fontSize: '10px',
padding: '2px 6px',
backgroundColor: '#8b5cf6',
color: 'white',
borderRadius: '10px',
borderRadius: '8px',
fontWeight: 500,
},
};

View File

@ -1,10 +1,13 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useAuth } from '../../context/AuthContext';
import { useEditor, useValue } from 'tldraw';
import CryptID from './CryptID';
import { GoogleDataService, type GoogleService } from '../../lib/google';
import { GoogleExportBrowser } from '../GoogleExportBrowser';
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from '../../lib/fathomApiKey';
import { isMiroApiKeyConfigured } from '../../lib/miroApiKey';
import { MiroIntegrationModal } from '../MiroIntegrationModal';
import { getMyConnections, createConnection, removeConnection, updateTrustLevel, updateEdgeMetadata } from '../../lib/networking/connectionService';
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from '../../lib/networking/types';
@ -22,6 +25,7 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
const [showCryptIDModal, setShowCryptIDModal] = useState(false);
const [showGoogleBrowser, setShowGoogleBrowser] = useState(false);
const [showObsidianModal, setShowObsidianModal] = useState(false);
const [showMiroModal, setShowMiroModal] = useState(false);
const [obsidianVaultUrl, setObsidianVaultUrl] = useState('');
const [googleConnected, setGoogleConnected] = useState(false);
const [googleLoading, setGoogleLoading] = useState(false);
@ -32,6 +36,7 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
calendar: 0,
});
const dropdownRef = useRef<HTMLDivElement>(null);
const dropdownMenuRef = useRef<HTMLDivElement>(null);
// Expanded sections (only integrations and connections now)
const [expandedSection, setExpandedSection] = useState<'none' | 'integrations' | 'connections'>('none');
@ -49,15 +54,10 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
const [savingMetadata, setSavingMetadata] = useState(false);
const [connectingUserId, setConnectingUserId] = useState<string | null>(null);
// Try to get editor (may not exist if outside tldraw context)
let editor: any = null;
let collaborators: any[] = [];
try {
editor = useEditor();
collaborators = useValue('collaborators', () => editor?.getCollaborators() || [], [editor]) || [];
} catch {
// Not inside tldraw context
}
// Get editor - will throw if outside tldraw context, but that's handled by ErrorBoundary
// Note: These hooks must always be called unconditionally
const editorFromHook = useEditor();
const collaborators = useValue('collaborators', () => editorFromHook?.getCollaborators() || [], [editorFromHook]) || [];
// Canvas users with their connection status
interface CanvasUser {
@ -190,7 +190,11 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
// Close dropdown when clicking outside or pressing ESC
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
const target = e.target as Node;
// Check if click is inside trigger button OR the portal dropdown menu
const isInsideTrigger = dropdownRef.current && dropdownRef.current.contains(target);
const isInsideMenu = dropdownMenuRef.current && dropdownMenuRef.current.contains(target);
if (!isInsideTrigger && !isInsideMenu) {
setShowDropdown(false);
}
};
@ -257,25 +261,40 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
return name.charAt(0).toUpperCase();
};
// If showing CryptID modal
if (showCryptIDModal) {
return (
<div className="cryptid-modal-overlay">
<div className="cryptid-modal">
<CryptID
onSuccess={() => setShowCryptIDModal(false)}
onCancel={() => setShowCryptIDModal(false)}
/>
</div>
</div>
);
}
// Ref for the trigger button to calculate dropdown position
const triggerRef = useRef<HTMLButtonElement>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
// Update dropdown position when it opens
useEffect(() => {
if (showDropdown && triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
});
}
}, [showDropdown]);
// Close dropdown when user logs out
useEffect(() => {
if (!session.authed) {
setShowDropdown(false);
}
}, [session.authed]);
return (
<div ref={dropdownRef} className="cryptid-dropdown" style={{ position: 'relative', pointerEvents: 'all' }}>
{/* Trigger button */}
<div ref={dropdownRef} className="cryptid-dropdown" style={{ pointerEvents: 'all' }}>
{/* Trigger button - opens modal directly for unauthenticated users, dropdown for authenticated */}
<button
onClick={() => setShowDropdown(!showDropdown)}
ref={triggerRef}
onClick={() => {
if (session.authed) {
setShowDropdown(!showDropdown);
} else {
setShowCryptIDModal(true);
}
}}
onPointerDown={(e) => e.stopPropagation()}
className="cryptid-trigger"
style={{
@ -295,82 +314,78 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
>
{session.authed ? (
<>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '11px',
fontWeight: 600,
color: 'white',
}}
>
{getInitials(session.username)}
</div>
{/* Locked lock icon */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
<span style={{ fontSize: '13px', fontWeight: 500, maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{session.username}
</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</>
) : (
<>
{/* Unlocked lock icon */}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
</svg>
<span style={{ fontSize: '13px', fontWeight: 500 }}>Sign In</span>
</>
)}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
{/* Dropdown menu */}
{showDropdown && (
{/* Dropdown menu - rendered via portal to break out of parent container */}
{showDropdown && dropdownPosition && createPortal(
<div
ref={dropdownMenuRef}
className="cryptid-dropdown-menu"
style={{
position: 'absolute',
top: 'calc(100% + 8px)',
right: 0,
position: 'fixed',
top: dropdownPosition.top,
right: dropdownPosition.right,
minWidth: '260px',
maxHeight: 'calc(100vh - 100px)',
background: 'var(--color-panel)',
border: '1px solid var(--color-panel-contrast)',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
background: 'var(--color-background)',
border: '1px solid var(--color-grid)',
borderRadius: '8px',
boxShadow: '0 4px 16px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.05)',
zIndex: 100000,
overflowY: 'auto',
overflowX: 'hidden',
pointerEvents: 'all',
fontFamily: 'var(--tl-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif)',
}}
onWheel={(e) => {
// Stop wheel events from propagating to canvas when over menu
e.stopPropagation();
}}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
>
{session.authed ? (
<>
{/* Account section */}
<div style={{ padding: '16px', borderBottom: '1px solid var(--color-panel-contrast)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ padding: '12px 14px', borderBottom: '1px solid var(--color-grid)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '50%',
width: '36px',
height: '36px',
borderRadius: '6px',
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '16px',
fontSize: '14px',
fontWeight: 600,
color: 'white',
}}
@ -378,79 +393,80 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
{getInitials(session.username)}
</div>
<div>
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>
{session.username}
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ color: '#22c55e' }}>&#x1F512;</span> CryptID secured
<div style={{ fontSize: '11px', color: 'var(--color-text-2)', display: 'flex', alignItems: 'center', gap: '4px' }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="#22c55e" stroke="#22c55e" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
CryptID secured
</div>
</div>
</div>
</div>
{/* Quick actions */}
<div style={{ padding: '8px 0', borderBottom: '1px solid var(--color-panel-contrast)' }}>
<div style={{ padding: '4px', borderBottom: '1px solid var(--color-grid)' }}>
<a
href="/dashboard/"
onPointerDown={(e) => e.stopPropagation()}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 16px',
gap: '8px',
padding: '8px 10px',
fontSize: '13px',
fontWeight: 500,
color: 'var(--color-text)',
textDecoration: 'none',
transition: 'background 0.15s, transform 0.15s',
borderRadius: '6px',
margin: '0 8px',
transition: 'background 0.1s',
borderRadius: '4px',
pointerEvents: 'all',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-muted-2)';
e.currentTarget.style.transform = 'translateX(2px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.transform = 'translateX(0)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="#f59e0b" stroke="#f59e0b" strokeWidth="1">
<svg width="15" height="15" viewBox="0 0 24 24" fill="var(--color-text-2)" stroke="none">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
My Saved Boards
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: 'auto', opacity: 0.5 }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-2)" strokeWidth="2" style={{ marginLeft: 'auto' }}>
<polyline points="9 18 15 12 9 6"/>
</svg>
</a>
</div>
{/* Integrations section */}
<div style={{ padding: '8px 0' }}>
<div style={{ padding: '4px' }}>
<div style={{
padding: '8px 16px',
fontSize: '10px',
fontWeight: 600,
color: 'var(--color-text-3)',
padding: '6px 10px',
fontSize: '11px',
fontWeight: 500,
color: 'var(--color-text-2)',
textTransform: 'uppercase',
letterSpacing: '0.5px',
letterSpacing: '0.3px',
}}>
Integrations
</div>
{/* Google Workspace */}
<div style={{ padding: '8px 16px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
<div style={{ padding: '6px 10px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<div style={{
width: '28px',
height: '28px',
borderRadius: '6px',
width: '24px',
height: '24px',
borderRadius: '4px',
background: 'linear-gradient(135deg, #4285F4, #34A853, #FBBC04, #EA4335)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
fontSize: '11px',
fontWeight: 600,
color: 'white',
}}>
@ -460,21 +476,21 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
Google Workspace
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
{googleConnected ? `${totalGoogleItems} items imported` : 'Not connected'}
</div>
</div>
{googleConnected && (
<span style={{
width: '8px',
height: '8px',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: '#22c55e',
}} />
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ display: 'flex', gap: '6px' }}>
{googleConnected ? (
<>
<button
@ -485,16 +501,25 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '8px 14px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: 600,
borderRadius: '6px',
borderRadius: '4px',
border: 'none',
background: 'linear-gradient(135deg, #4285F4, #34A853)',
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
color: 'white',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(66, 133, 244, 0.3)',
pointerEvents: 'all',
transition: 'all 0.15s',
boxShadow: '0 2px 4px rgba(139, 92, 246, 0.3)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
}}
>
Browse Data
@ -503,15 +528,22 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
onClick={handleGoogleDisconnect}
onPointerDown={(e) => e.stopPropagation()}
style={{
padding: '8px 14px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: 500,
borderRadius: '6px',
border: '1px solid var(--color-panel-contrast)',
backgroundColor: 'var(--color-muted-2)',
color: 'var(--color-text)',
borderRadius: '4px',
border: 'none',
background: '#6b7280',
color: 'white',
cursor: 'pointer',
pointerEvents: 'all',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#4b5563';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6b7280';
}}
>
Disconnect
@ -524,26 +556,28 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
disabled={googleLoading}
style={{
flex: 1,
padding: '8px 16px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: 600,
borderRadius: '6px',
borderRadius: '4px',
border: 'none',
background: 'linear-gradient(135deg, #4285F4, #34A853)',
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
color: 'white',
cursor: googleLoading ? 'wait' : 'pointer',
opacity: googleLoading ? 0.7 : 1,
transition: 'transform 0.15s, box-shadow 0.15s',
boxShadow: '0 2px 8px rgba(66, 133, 244, 0.3)',
transition: 'all 0.15s',
pointerEvents: 'all',
boxShadow: '0 2px 4px rgba(139, 92, 246, 0.3)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(66, 133, 244, 0.4)';
if (!googleLoading) {
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(66, 133, 244, 0.3)';
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
}}
>
{googleLoading ? 'Connecting...' : 'Connect Google'}
@ -553,17 +587,17 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
</div>
{/* Obsidian Vault */}
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-panel-contrast)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
<div style={{ padding: '6px 10px', borderTop: '1px solid var(--color-grid)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<div style={{
width: '28px',
height: '28px',
borderRadius: '6px',
width: '24px',
height: '24px',
borderRadius: '4px',
background: '#7c3aed',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontSize: '12px',
}}>
📁
</div>
@ -571,14 +605,14 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
Obsidian Vault
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
{session.obsidianVaultName || 'Not connected'}
</div>
</div>
{session.obsidianVaultName && (
<span style={{
width: '8px',
height: '8px',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: '#22c55e',
}} />
@ -592,29 +626,37 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '100%',
padding: '8px 16px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: 600,
borderRadius: '6px',
borderRadius: '4px',
border: 'none',
background: session.obsidianVaultName
? 'var(--color-muted-2)'
: 'linear-gradient(135deg, #7c3aed 0%, #a855f7 100%)',
color: session.obsidianVaultName ? 'var(--color-text)' : 'white',
? '#6b7280'
: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
color: 'white',
cursor: 'pointer',
boxShadow: session.obsidianVaultName ? 'none' : '0 2px 8px rgba(124, 58, 237, 0.3)',
pointerEvents: 'all',
transition: 'transform 0.15s, box-shadow 0.15s',
transition: 'all 0.15s',
boxShadow: session.obsidianVaultName
? 'none'
: '0 2px 4px rgba(139, 92, 246, 0.3)',
}}
onMouseEnter={(e) => {
if (!session.obsidianVaultName) {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(124, 58, 237, 0.4)';
if (session.obsidianVaultName) {
e.currentTarget.style.background = '#4b5563';
} else {
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = session.obsidianVaultName ? 'none' : '0 2px 8px rgba(124, 58, 237, 0.3)';
if (session.obsidianVaultName) {
e.currentTarget.style.background = '#6b7280';
} else {
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
}
}}
>
{session.obsidianVaultName ? 'Change Vault' : 'Connect Vault'}
@ -622,17 +664,17 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
</div>
{/* Fathom Meetings */}
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-panel-contrast)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
<div style={{ padding: '6px 10px', borderTop: '1px solid var(--color-grid)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<div style={{
width: '28px',
height: '28px',
borderRadius: '6px',
width: '24px',
height: '24px',
borderRadius: '4px',
background: '#ef4444',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontSize: '12px',
}}>
🎥
</div>
@ -640,14 +682,14 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
Fathom Meetings
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
{hasFathomApiKey ? 'Connected' : 'Not connected'}
</div>
</div>
{hasFathomApiKey && (
<span style={{
width: '8px',
height: '8px',
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: '#22c55e',
}} />
@ -664,11 +706,12 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
width: '100%',
padding: '6px 8px',
fontSize: '11px',
border: '1px solid var(--color-panel-contrast)',
border: '1px solid var(--color-grid)',
borderRadius: '4px',
marginBottom: '6px',
background: 'var(--color-panel)',
background: 'var(--color-background)',
color: 'var(--color-text)',
boxSizing: 'border-box',
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && fathomKeyInput.trim()) {
@ -696,12 +739,12 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '6px 10px',
fontSize: '11px',
fontWeight: 600,
padding: '6px 12px',
fontSize: '12px',
fontWeight: 500,
backgroundColor: '#3b82f6',
color: 'white',
border: 'none',
border: '1px solid #2563eb',
borderRadius: '4px',
cursor: 'pointer',
pointerEvents: 'all',
@ -717,15 +760,22 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '6px 10px',
fontSize: '11px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: 500,
backgroundColor: 'var(--color-muted-2)',
border: 'none',
backgroundColor: 'var(--color-low)',
border: '1px solid var(--color-grid)',
borderRadius: '4px',
cursor: 'pointer',
color: 'var(--color-text)',
pointerEvents: 'all',
transition: 'background 0.1s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-muted-2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'var(--color-low)';
}}
>
Cancel
@ -733,7 +783,7 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
</div>
</div>
) : (
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ display: 'flex', gap: '6px' }}>
<button
onClick={() => {
setShowFathomInput(true);
@ -743,29 +793,37 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
onPointerDown={(e) => e.stopPropagation()}
style={{
flex: 1,
padding: '8px 16px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: 600,
borderRadius: '6px',
borderRadius: '4px',
border: 'none',
background: hasFathomApiKey
? 'var(--color-muted-2)'
: 'linear-gradient(135deg, #ef4444 0%, #f97316 100%)',
color: hasFathomApiKey ? 'var(--color-text)' : 'white',
? '#6b7280'
: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
color: 'white',
cursor: 'pointer',
boxShadow: hasFathomApiKey ? 'none' : '0 2px 8px rgba(239, 68, 68, 0.3)',
pointerEvents: 'all',
transition: 'transform 0.15s, box-shadow 0.15s',
transition: 'all 0.15s',
boxShadow: hasFathomApiKey
? 'none'
: '0 2px 4px rgba(139, 92, 246, 0.3)',
}}
onMouseEnter={(e) => {
if (!hasFathomApiKey) {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(239, 68, 68, 0.4)';
if (hasFathomApiKey) {
e.currentTarget.style.background = '#4b5563';
} else {
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = hasFathomApiKey ? 'none' : '0 2px 8px rgba(239, 68, 68, 0.3)';
if (hasFathomApiKey) {
e.currentTarget.style.background = '#6b7280';
} else {
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
}
}}
>
{hasFathomApiKey ? 'Change Key' : 'Add API Key'}
@ -778,15 +836,22 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
padding: '8px 14px',
padding: '6px 12px',
fontSize: '12px',
fontWeight: 500,
borderRadius: '6px',
backgroundColor: '#fee2e2',
color: '#dc2626',
borderRadius: '4px',
border: 'none',
background: '#6b7280',
color: 'white',
cursor: 'pointer',
pointerEvents: 'all',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#4b5563';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6b7280';
}}
>
Disconnect
@ -795,10 +860,90 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
</div>
)}
</div>
{/* Miro Board Import */}
<div style={{ padding: '6px 10px', borderTop: '1px solid var(--color-grid)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<div style={{
width: '24px',
height: '24px',
borderRadius: '4px',
background: 'linear-gradient(135deg, #ffd02f 0%, #f2c94c 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M17.5 3H21V21H17.5L14 12L17.5 3Z" fill="#050038"/>
<path d="M10.5 3H14L10.5 12L14 21H10.5L7 12L10.5 3Z" fill="#050038"/>
<path d="M3 3H6.5L3 12L6.5 21H3V3Z" fill="#050038"/>
</svg>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
Miro Boards
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
{isMiroApiKeyConfigured(session.username) ? 'API connected' : 'Import via JSON'}
</div>
</div>
{isMiroApiKeyConfigured(session.username) && (
<span style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: '#22c55e',
}} />
)}
</div>
<button
onClick={() => {
setShowMiroModal(true);
setShowDropdown(false);
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '100%',
padding: '6px 12px',
fontSize: '12px',
fontWeight: 600,
borderRadius: '4px',
border: 'none',
background: isMiroApiKeyConfigured(session.username)
? '#6b7280'
: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
color: 'white',
cursor: 'pointer',
pointerEvents: 'all',
transition: 'all 0.15s',
boxShadow: isMiroApiKeyConfigured(session.username)
? 'none'
: '0 2px 4px rgba(139, 92, 246, 0.3)',
}}
onMouseEnter={(e) => {
if (isMiroApiKeyConfigured(session.username)) {
e.currentTarget.style.background = '#4b5563';
} else {
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
}
}}
onMouseLeave={(e) => {
if (isMiroApiKeyConfigured(session.username)) {
e.currentTarget.style.background = '#6b7280';
} else {
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
}
}}
>
Import Miro Board
</button>
</div>
</div>
{/* Sign out */}
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-panel-contrast)' }}>
<div style={{ padding: '8px 10px', borderTop: '1px solid var(--color-grid)' }}>
<button
onClick={async () => {
await logout();
@ -806,27 +951,29 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
}}
style={{
width: '100%',
padding: '8px 12px',
fontSize: '13px',
padding: '8px 16px',
fontSize: '12px',
fontWeight: 500,
borderRadius: '6px',
borderRadius: '4px',
border: 'none',
backgroundColor: 'transparent',
color: 'var(--color-text-3)',
backgroundColor: '#6b7280',
color: 'white',
cursor: 'pointer',
textAlign: 'left',
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--color-muted-2)';
e.currentTarget.style.backgroundColor = '#4b5563';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.backgroundColor = '#6b7280';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<polyline points="16 17 21 12 16 7"></polyline>
<line x1="21" y1="12" x2="9" y2="12"></line>
@ -835,52 +982,24 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
</button>
</div>
</>
) : (
<div style={{ padding: '16px' }}>
<div style={{ marginBottom: '12px', textAlign: 'center' }}>
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)', marginBottom: '4px' }}>
Sign in with CryptID
</div>
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.4 }}>
Create a username to edit boards and sync your data across devices.
</div>
</div>
<button
onClick={() => {
setShowCryptIDModal(true);
setShowDropdown(false);
}}
style={{
width: '100%',
padding: '10px 16px',
fontSize: '13px',
fontWeight: 600,
borderRadius: '8px',
border: 'none',
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
color: 'white',
cursor: 'pointer',
}}
>
Create or Sign In
</button>
</div>
)}
</div>
) : null}
</div>,
document.body
)}
{/* Google Export Browser Modal */}
{showGoogleBrowser && (
{showGoogleBrowser && createPortal(
<GoogleExportBrowser
isOpen={showGoogleBrowser}
onClose={() => setShowGoogleBrowser(false)}
onAddToCanvas={handleAddToCanvas}
isDarkMode={isDarkMode}
/>
/>,
document.body
)}
{/* Obsidian Vault Connection Modal */}
{showObsidianModal && (
{showObsidianModal && createPortal(
<div
style={{
position: 'fixed',
@ -1110,7 +1229,88 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
Cancel
</button>
</div>
</div>
</div>,
document.body
)}
{/* Miro Integration Modal */}
{showMiroModal && createPortal(
<MiroIntegrationModal
isOpen={showMiroModal}
onClose={() => setShowMiroModal(false)}
username={session.username}
/>,
document.body
)}
{/* CryptID Sign In Modal - rendered via portal */}
{showCryptIDModal && createPortal(
<div
className="cryptid-modal-overlay"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 999999,
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowCryptIDModal(false);
}
}}
>
<div
className="cryptid-modal"
style={{
backgroundColor: 'var(--color-panel, #ffffff)',
borderRadius: '16px',
padding: '0',
maxWidth: '580px',
width: '95vw',
maxHeight: '90vh',
boxShadow: '0 25px 80px rgba(0, 0, 0, 0.4)',
overflow: 'auto',
position: 'relative',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<button
onClick={() => setShowCryptIDModal(false)}
style={{
position: 'absolute',
top: '12px',
right: '12px',
background: 'var(--color-muted-2, #f3f4f6)',
border: 'none',
borderRadius: '50%',
width: '28px',
height: '28px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-2, #6b7280)',
fontSize: '16px',
zIndex: 1,
}}
>
×
</button>
<CryptID
onSuccess={() => setShowCryptIDModal(false)}
onCancel={() => setShowCryptIDModal(false)}
/>
</div>
</div>,
document.body
)}
</div>
);

View File

@ -17,7 +17,7 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as d3 from 'd3';
import { type GraphNode, type GraphEdge, type TrustLevel, TRUST_LEVEL_COLORS } from '../../lib/networking';
import { type GraphNode, type GraphEdge, type TrustLevel } from '../../lib/networking';
import { UserSearchModal } from './UserSearchModal';
// =============================================================================
@ -32,6 +32,9 @@ interface NetworkGraphMinimapProps {
onConnect: (userId: string, trustLevel?: TrustLevel) => Promise<void>;
onDisconnect?: (connectionId: string) => Promise<void>;
onNodeClick?: (node: GraphNode) => void;
onGoToUser?: (node: GraphNode) => void;
onFollowUser?: (node: GraphNode) => void;
onOpenProfile?: (node: GraphNode) => void;
onEdgeClick?: (edge: GraphEdge) => void;
onExpandClick?: () => void;
width?: number;
@ -132,25 +135,6 @@ const getStyles = (isDarkMode: boolean) => ({
collapsedIcon: {
fontSize: '20px',
},
stats: {
display: 'flex',
gap: '12px',
padding: '6px 12px',
borderTop: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.08)',
fontSize: '11px',
color: isDarkMode ? '#888' : '#6b7280',
backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.02)',
},
stat: {
display: 'flex',
alignItems: 'center',
gap: '4px',
},
statDot: {
width: '8px',
height: '8px',
borderRadius: '50%',
},
});
// =============================================================================
@ -165,6 +149,9 @@ export function NetworkGraphMinimap({
onConnect,
onDisconnect,
onNodeClick,
onGoToUser,
onFollowUser,
onOpenProfile,
onEdgeClick,
onExpandClick,
width = 240,
@ -183,13 +170,6 @@ export function NetworkGraphMinimap({
// Get theme-aware styles
const styles = React.useMemo(() => getStyles(isDarkMode), [isDarkMode]);
// Count stats
const inRoomCount = nodes.filter(n => n.isInRoom).length;
const anonymousCount = nodes.filter(n => n.isAnonymous).length;
const trustedCount = nodes.filter(n => n.trustLevelTo === 'trusted').length;
const connectedCount = nodes.filter(n => n.trustLevelTo === 'connected').length;
const unconnectedCount = nodes.filter(n => !n.trustLevelTo && !n.isCurrentUser && !n.isAnonymous).length;
// Initialize and update the D3 simulation
useEffect(() => {
if (!svgRef.current || isCollapsed || nodes.length === 0) return;
@ -313,41 +293,18 @@ export function NetworkGraphMinimap({
}
});
// Helper to get node color based on trust level and room status
// Priority: current user (purple) > anonymous (grey) > trust level > unconnected (white)
// Helper to get node color - uses the user's profile/presence color
const getNodeColor = (d: SimulationNode) => {
if (d.isCurrentUser) {
return '#4f46e5'; // Current user is always purple
// Use room presence color (user's profile color) if available
if (d.roomPresenceColor) {
return d.roomPresenceColor;
}
// Anonymous users are grey
if (d.isAnonymous) {
return TRUST_LEVEL_COLORS.anonymous;
// Use avatar color as fallback
if (d.avatarColor) {
return d.avatarColor;
}
// If in room and has presence color, use it for the stroke/ring instead
// (we still use trust level for fill to maintain visual consistency)
// Otherwise use trust level color
if (d.trustLevelTo) {
return TRUST_LEVEL_COLORS[d.trustLevelTo];
}
// Authenticated but unconnected = white
return TRUST_LEVEL_COLORS.unconnected;
};
// Helper to get node stroke color (for in-room presence indicator)
const getNodeStroke = (d: SimulationNode) => {
if (d.isCurrentUser) return '#fff';
// Show room presence color as a ring around the node
if (d.isInRoom && d.roomPresenceColor) return d.roomPresenceColor;
// White nodes need a subtle border to be visible
if (!d.isAnonymous && !d.trustLevelTo) return '#e5e7eb';
return 'none';
};
const getNodeStrokeWidth = (d: SimulationNode) => {
if (d.isCurrentUser) return 2;
if (d.isInRoom && d.roomPresenceColor) return 2;
if (!d.isAnonymous && !d.trustLevelTo) return 1;
return 0;
// Default grey for users without a color
return '#9ca3af';
};
// Create nodes
@ -358,15 +315,13 @@ export function NetworkGraphMinimap({
.join('circle')
.attr('r', d => d.isCurrentUser ? 8 : 6)
.attr('fill', d => getNodeColor(d))
.attr('stroke', d => getNodeStroke(d))
.attr('stroke-width', d => getNodeStrokeWidth(d))
.style('cursor', 'pointer')
.on('mouseenter', (event, d) => {
const rect = svgRef.current!.getBoundingClientRect();
setTooltip({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
text: `${d.displayName || d.username}${d.isAnonymous ? ' (anonymous)' : ''}`,
text: d.displayName || d.username,
});
})
.on('mouseleave', () => {
@ -374,12 +329,12 @@ export function NetworkGraphMinimap({
})
.on('click', (event, d) => {
event.stopPropagation();
// Don't show popup for current user or anonymous users
if (d.isCurrentUser || d.isAnonymous) {
// Don't show popup for current user
if (d.isCurrentUser) {
if (onNodeClick) onNodeClick(d);
return;
}
// Show connection popup
// Show dropdown menu for all other users
const rect = svgRef.current!.getBoundingClientRect();
setSelectedNode({
node: d,
@ -502,187 +457,142 @@ export function NetworkGraphMinimap({
</div>
)}
{/* Connection popup when clicking a node */}
{selectedNode && !selectedNode.node.isCurrentUser && !selectedNode.node.isAnonymous && (
{/* User action dropdown menu when clicking a node */}
{selectedNode && !selectedNode.node.isCurrentUser && (
<div
style={{
position: 'absolute',
left: Math.min(selectedNode.x, width - 140),
top: Math.max(selectedNode.y - 80, 10),
backgroundColor: 'white',
left: Math.min(selectedNode.x, width - 160),
top: Math.max(selectedNode.y - 10, 10),
backgroundColor: isDarkMode ? '#1e1e2e' : 'white',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
padding: '8px',
boxShadow: isDarkMode ? '0 4px 12px rgba(0, 0, 0, 0.4)' : '0 4px 12px rgba(0, 0, 0, 0.15)',
padding: '6px',
zIndex: 1002,
minWidth: '130px',
minWidth: '150px',
border: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ fontSize: '11px', fontWeight: 600, marginBottom: '6px', color: '#1a1a2e' }}>
{selectedNode.node.displayName || selectedNode.node.username}
</div>
<div style={{ fontSize: '10px', color: '#666', marginBottom: '8px' }}>
@{selectedNode.node.username}
</div>
{/* Connection actions */}
{selectedNode.node.trustLevelTo ? (
// Already connected - show trust level options
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<button
onClick={async () => {
// Toggle trust level
const newLevel = selectedNode.node.trustLevelTo === 'trusted' ? 'connected' : 'trusted';
setIsConnecting(true);
// This would need updateTrustLevel function passed as prop
// For now, just close the popup
setSelectedNode(null);
setIsConnecting(false);
}}
disabled={isConnecting}
style={{
padding: '6px 10px',
fontSize: '10px',
backgroundColor: selectedNode.node.trustLevelTo === 'trusted' ? '#fef3c7' : '#d1fae5',
color: selectedNode.node.trustLevelTo === 'trusted' ? '#92400e' : '#065f46',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{selectedNode.node.trustLevelTo === 'trusted' ? 'Downgrade to Connected' : 'Upgrade to Trusted'}
</button>
<button
onClick={async () => {
setIsConnecting(true);
try {
// Find connection ID and disconnect
const edge = edges.find(e =>
(e.source === currentUserId && e.target === selectedNode.node.id) ||
(e.target === currentUserId && e.source === selectedNode.node.id)
);
if (edge && onDisconnect) {
await onDisconnect(edge.id);
}
} catch (err) {
console.error('Failed to disconnect:', err);
}
setSelectedNode(null);
setIsConnecting(false);
}}
disabled={isConnecting}
style={{
padding: '6px 10px',
fontSize: '10px',
backgroundColor: '#fee2e2',
color: '#dc2626',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{isConnecting ? 'Removing...' : 'Remove Connection'}
</button>
</div>
) : (
// Not connected - show connect options
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<button
onClick={async () => {
setIsConnecting(true);
try {
// Use username for API call (CryptID username), not tldraw session id
const userId = selectedNode.node.username || selectedNode.node.id;
await onConnect(userId, 'connected');
} catch (err) {
console.error('Failed to connect:', err);
}
setSelectedNode(null);
setIsConnecting(false);
}}
disabled={isConnecting}
style={{
padding: '6px 10px',
fontSize: '10px',
backgroundColor: '#fef3c7',
color: '#92400e',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{isConnecting ? 'Connecting...' : 'Connect (View)'}
</button>
<button
onClick={async () => {
setIsConnecting(true);
try {
// Use username for API call (CryptID username), not tldraw session id
// Connect with trusted level directly
const userId = selectedNode.node.username || selectedNode.node.id;
await onConnect(userId, 'trusted');
} catch (err) {
console.error('Failed to connect:', err);
}
setSelectedNode(null);
setIsConnecting(false);
}}
disabled={isConnecting}
style={{
padding: '6px 10px',
fontSize: '10px',
backgroundColor: '#d1fae5',
color: '#065f46',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{isConnecting ? 'Connecting...' : 'Trust (Edit)'}
</button>
</div>
{/* Connect option - only for non-anonymous users */}
{!selectedNode.node.isAnonymous && (
<button
onClick={async () => {
setIsConnecting(true);
try {
const userId = selectedNode.node.username || selectedNode.node.id;
await onConnect(userId, 'connected');
} catch (err) {
console.error('Failed to connect:', err);
}
setSelectedNode(null);
setIsConnecting(false);
}}
disabled={isConnecting}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '11px',
backgroundColor: 'transparent',
color: isDarkMode ? '#fbbf24' : '#92400e',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<span>🔗</span> Connect with {selectedNode.node.displayName || selectedNode.node.username}
</button>
)}
<button
onClick={() => setSelectedNode(null)}
style={{
marginTop: '6px',
width: '100%',
padding: '4px',
fontSize: '9px',
backgroundColor: 'transparent',
color: '#666',
border: 'none',
cursor: 'pointer',
}}
>
Cancel
</button>
</div>
)}
</div>
{/* Navigate option - only for in-room users */}
{selectedNode.node.isInRoom && onGoToUser && (
<button
onClick={() => {
onGoToUser(selectedNode.node);
setSelectedNode(null);
}}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '11px',
backgroundColor: 'transparent',
color: isDarkMode ? '#a0a0ff' : '#4f46e5',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<span>📍</span> Navigate to {selectedNode.node.displayName || selectedNode.node.username}
</button>
)}
<div style={styles.stats}>
<div style={styles.stat} title="Users in this room">
<div style={{ ...styles.statDot, backgroundColor: '#4f46e5' }} />
<span>{inRoomCount}</span>
</div>
<div style={styles.stat} title="Trusted (edit access)">
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.trusted }} />
<span>{trustedCount}</span>
</div>
<div style={styles.stat} title="Connected (view access)">
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.connected }} />
<span>{connectedCount}</span>
</div>
<div style={styles.stat} title="Unconnected">
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.unconnected, border: '1px solid #e5e7eb' }} />
<span>{unconnectedCount}</span>
</div>
{anonymousCount > 0 && (
<div style={styles.stat} title="Anonymous">
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.anonymous }} />
<span>{anonymousCount}</span>
{/* Screenfollow option - only for in-room users */}
{selectedNode.node.isInRoom && onFollowUser && (
<button
onClick={() => {
onFollowUser(selectedNode.node);
setSelectedNode(null);
}}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '11px',
backgroundColor: 'transparent',
color: isDarkMode ? '#60a5fa' : '#2563eb',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<span>👁</span> Screenfollow {selectedNode.node.displayName || selectedNode.node.username}
</button>
)}
{/* Open profile option - only for non-anonymous users */}
{!selectedNode.node.isAnonymous && onOpenProfile && (
<button
onClick={() => {
onOpenProfile(selectedNode.node);
setSelectedNode(null);
}}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '11px',
backgroundColor: 'transparent',
color: isDarkMode ? '#e0e0e0' : '#374151',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<span>👤</span> Open {selectedNode.node.displayName || selectedNode.node.username}'s profile
</button>
)}
</div>
)}
</div>

View File

@ -69,10 +69,10 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
},
];
// Add collaborators
// Add collaborators - TLInstancePresence has userId and userName
collaborators.forEach((c: any) => {
participants.push({
id: c.id || c.userId || c.instanceId,
id: c.userId || c.id,
username: c.userName || 'Anonymous',
color: c.color,
});
@ -112,6 +112,58 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
console.log('Node clicked:', node);
}, []);
// Handle going to a user's cursor on canvas (navigate/pan to their location)
const handleGoToUser = useCallback((node: any) => {
if (!editor) return;
// Find the collaborator's cursor position
// TLInstancePresence has userId and userName properties
const targetCollaborator = collaborators.find((c: any) =>
c.id === node.id ||
c.userId === node.id ||
c.userName === node.username
);
if (targetCollaborator && targetCollaborator.cursor) {
// Pan to the user's cursor position
const { x, y } = targetCollaborator.cursor;
editor.centerOnPoint({ x, y });
} else {
// If no cursor position, try to find any presence data
console.log('Could not find cursor position for user:', node.username);
}
}, [editor, collaborators]);
// Handle screen following a user (camera follows their view)
const handleFollowUser = useCallback((node: any) => {
if (!editor) return;
// Find the collaborator to follow
// TLInstancePresence has userId and userName properties
const targetCollaborator = collaborators.find((c: any) =>
c.id === node.id ||
c.userId === node.id ||
c.userName === node.username
);
if (targetCollaborator) {
// Use tldraw's built-in follow functionality - needs userId
const userId = targetCollaborator.userId || targetCollaborator.id;
editor.startFollowingUser(userId);
console.log('Now following user:', node.username);
} else {
console.log('Could not find user to follow:', node.username);
}
}, [editor, collaborators]);
// Handle opening a user's profile
const handleOpenProfile = useCallback((node: any) => {
// Open user profile in a new tab or modal
const username = node.username || node.id;
// Navigate to user profile page
window.open(`/profile/${username}`, '_blank');
}, []);
// Handle edge click
const handleEdgeClick = useCallback((edge: GraphEdge) => {
setSelectedEdge(edge);
@ -156,6 +208,9 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onNodeClick={handleNodeClick}
onGoToUser={handleGoToUser}
onFollowUser={handleFollowUser}
onOpenProfile={handleOpenProfile}
onEdgeClick={handleEdgeClick}
onExpandClick={handleExpand}
isCollapsed={isCollapsed}

View File

@ -155,6 +155,45 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
try {
setState(prev => ({ ...prev, isLoading: !prev.nodes.length }));
// Double-check authentication before making API calls
// This handles race conditions where session state might not be updated yet
const currentUserId = (() => {
try {
// Session is stored as 'canvas_auth_session' by sessionPersistence.ts
const sessionStr = localStorage.getItem('canvas_auth_session');
if (sessionStr) {
const s = JSON.parse(sessionStr);
if (s.authed && s.username) return s.username;
}
} catch { /* ignore */ }
return null;
})();
if (!currentUserId) {
// Not authenticated - use room participants only
const anonymousNodes: GraphNode[] = roomParticipants.map(participant => ({
id: participant.id,
username: participant.username,
displayName: participant.username,
avatarColor: participant.color,
isInRoom: true,
roomPresenceColor: participant.color,
isCurrentUser: participant.id === roomParticipants[0]?.id,
isAnonymous: true,
trustLevelTo: undefined,
trustLevelFrom: undefined,
}));
setState({
nodes: anonymousNodes,
edges: [],
myConnections: [],
isLoading: false,
error: null,
});
return;
}
// Fetch graph, optionally scoped to room
let graph: NetworkGraph;
try {
@ -165,7 +204,10 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
}
} catch (apiError: any) {
// If API call fails (e.g., 401 Unauthorized), fall back to showing room participants
console.warn('Network graph API failed, falling back to room participants:', apiError.message);
// Only log if it's not a 401 (which is expected for auth issues)
if (!apiError.message?.includes('401')) {
console.warn('Network graph API failed, falling back to room participants:', apiError.message);
}
const fallbackNodes: GraphNode[] = roomParticipants.map(participant => ({
id: participant.id,
username: participant.username,

View File

@ -105,7 +105,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const result = await AuthService.login(username);
if (result.success && result.session) {
setSessionState(result.session);
// IMPORTANT: Clear permission cache when auth state changes
// This forces a fresh permission fetch with the new credentials
setSessionState({
...result.session,
boardPermissions: {},
currentBoardPermission: undefined,
});
console.log('🔐 Login successful - cleared permission cache');
// Save session to localStorage if authenticated
if (result.session.authed && result.session.username) {
@ -141,7 +148,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const result = await AuthService.register(username);
if (result.success && result.session) {
setSessionState(result.session);
// IMPORTANT: Clear permission cache when auth state changes
// This forces a fresh permission fetch with the new credentials
setSessionState({
...result.session,
boardPermissions: {},
currentBoardPermission: undefined,
});
console.log('🔐 Registration successful - cleared permission cache');
// Save session to localStorage if authenticated
if (result.session.authed && result.session.username) {
@ -178,7 +192,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
loading: false,
backupCreated: null,
obsidianVaultPath: undefined,
obsidianVaultName: undefined
obsidianVaultName: undefined,
// IMPORTANT: Clear permission cache on logout to force fresh fetch on next login
boardPermissions: {},
currentBoardPermission: undefined,
});
}, []);
@ -215,6 +232,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const fetchBoardPermission = useCallback(async (boardId: string): Promise<PermissionLevel> => {
// Check cache first (but only if no access token - token changes permissions)
if (!accessToken && session.boardPermissions?.[boardId]) {
console.log('🔐 Using cached permission for board:', boardId, session.boardPermissions[boardId]);
return session.boardPermissions[boardId];
}
@ -224,13 +242,24 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
'Content-Type': 'application/json',
};
let publicKeyUsed: string | null = null;
if (session.authed && session.username) {
const publicKey = crypto.getPublicKey(session.username);
if (publicKey) {
headers['X-CryptID-PublicKey'] = publicKey;
publicKeyUsed = publicKey;
}
}
// Debug: Log what we're sending
console.log('🔐 fetchBoardPermission:', {
boardId,
sessionAuthed: session.authed,
sessionUsername: session.username,
publicKeyUsed: publicKeyUsed ? `${publicKeyUsed.substring(0, 20)}...` : null,
hasAccessToken: !!accessToken
});
// Build URL with optional access token
let url = `${WORKER_URL}/boards/${boardId}/permission`;
if (accessToken) {
@ -245,8 +274,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (!response.ok) {
console.error('Failed to fetch board permission:', response.status);
// Default to 'view' for unauthenticated (secure by default)
return 'view';
// Default to 'edit' for authenticated users, 'view' for unauthenticated
const defaultPermission: PermissionLevel = session.authed ? 'edit' : 'view';
console.log('🔐 Using default permission (API failed):', defaultPermission);
return defaultPermission;
}
const data = await response.json() as {
@ -254,27 +285,47 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
isOwner: boolean;
boardExists: boolean;
grantedByToken?: boolean;
isExplicitPermission?: boolean; // Whether this permission was explicitly set
};
// Debug: Log what we received
console.log('🔐 Permission response:', data);
if (data.grantedByToken) {
console.log('🔓 Permission granted via access token:', data.permission);
}
// Determine effective permission
// If authenticated user and board doesn't have explicit permissions set,
// default to 'edit' instead of 'view'
let effectivePermission = data.permission;
if (session.authed && data.permission === 'view') {
// If board doesn't exist in permission system or permission isn't explicitly set,
// authenticated users should get edit access by default
if (!data.boardExists || data.isExplicitPermission === false) {
effectivePermission = 'edit';
console.log('🔓 Upgrading to edit: authenticated user with no explicit view restriction');
}
}
// Cache the permission
setSessionState(prev => ({
...prev,
currentBoardPermission: data.permission,
currentBoardPermission: effectivePermission,
boardPermissions: {
...prev.boardPermissions,
[boardId]: data.permission,
[boardId]: effectivePermission,
},
}));
return data.permission;
return effectivePermission;
} catch (error) {
console.error('Error fetching board permission:', error);
// Default to 'view' (secure by default)
return 'view';
// Default to 'edit' for authenticated users, 'view' for unauthenticated
const defaultPermission: PermissionLevel = session.authed ? 'edit' : 'view';
console.log('🔐 Using default permission (error):', defaultPermission);
return defaultPermission;
}
}, [session.authed, session.username, session.boardPermissions, accessToken]);

View File

@ -0,0 +1,39 @@
import React, { createContext, useContext, ReactNode } from 'react'
import { ConnectionState } from '@/automerge/CloudflareAdapter'
interface ConnectionContextValue {
connectionState: ConnectionState
isNetworkOnline: boolean
}
const ConnectionContext = createContext<ConnectionContextValue | null>(null)
interface ConnectionProviderProps {
connectionState: ConnectionState
isNetworkOnline: boolean
children: ReactNode
}
export function ConnectionProvider({
connectionState,
isNetworkOnline,
children,
}: ConnectionProviderProps) {
return (
<ConnectionContext.Provider value={{ connectionState, isNetworkOnline }}>
{children}
</ConnectionContext.Provider>
)
}
export function useConnectionStatus() {
const context = useContext(ConnectionContext)
if (!context) {
// Return default values when not in provider (e.g., during SSR or outside Board)
return {
connectionState: 'connected' as ConnectionState,
isNetworkOnline: true,
}
}
return context
}

View File

@ -1,33 +1,32 @@
/* Anonymous Viewer Banner Styles */
/* Anonymous Viewer Banner Styles - Compact unified sign-in box (~33% smaller) */
.anonymous-viewer-banner {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 10000;
top: 56px;
right: 10px;
z-index: 100000;
max-width: 600px;
width: calc(100% - 40px);
max-width: 200px;
width: auto;
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 16px;
border-radius: 8px;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.3),
0 0 40px rgba(139, 92, 246, 0.15);
0 4px 16px rgba(0, 0, 0, 0.2),
0 0 10px rgba(139, 92, 246, 0.08);
animation: slideUp 0.4s ease-out;
animation: slideDown 0.25s ease-out;
}
@keyframes slideUp {
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
transform: translateY(0);
}
}
@ -35,22 +34,29 @@
background: linear-gradient(135deg, #2d1f3d 0%, #3d2d54 100%);
border-color: rgba(236, 72, 153, 0.4);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.3),
0 0 40px rgba(236, 72, 153, 0.2);
0 8px 24px rgba(0, 0, 0, 0.25),
0 0 20px rgba(236, 72, 153, 0.15);
}
.banner-content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
padding-top: 8px;
}
.banner-header {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px;
gap: 8px;
}
.banner-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 12px;
width: 24px;
height: 24px;
border-radius: 6px;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
display: flex;
align-items: center;
@ -58,6 +64,11 @@
color: white;
}
.banner-icon svg {
width: 14px;
height: 14px;
}
.edit-triggered .banner-icon {
background: linear-gradient(135deg, #ec4899 0%, #a855f7 100%);
}
@ -68,10 +79,10 @@
}
.banner-headline {
margin: 0 0 8px 0;
font-size: 16px;
margin: 0 0 2px 0;
font-size: 11px;
color: #f0f0f0;
line-height: 1.4;
line-height: 1.3;
}
.banner-headline strong {
@ -80,75 +91,27 @@
.banner-summary {
margin: 0;
font-size: 14px;
font-size: 10px;
color: #a0a0b0;
line-height: 1.5;
}
.banner-details {
margin-top: 8px;
}
.banner-details p {
margin: 0 0 12px 0;
font-size: 14px;
color: #c0c0d0;
line-height: 1.5;
}
.banner-details strong {
color: #8b5cf6;
}
.cryptid-benefits {
margin: 0;
padding: 0;
list-style: none;
}
.cryptid-benefits li {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 8px;
font-size: 13px;
color: #a0a0b0;
line-height: 1.4;
}
.cryptid-benefits li:last-child {
margin-bottom: 0;
}
.benefit-icon {
flex-shrink: 0;
font-size: 14px;
}
.cryptid-benefits a {
color: #8b5cf6;
text-decoration: none;
}
.cryptid-benefits a:hover {
text-decoration: underline;
line-height: 1.3;
}
.banner-actions {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
flex-direction: row;
gap: 6px;
width: 100%;
}
.banner-signup-btn {
padding: 10px 20px;
font-size: 14px;
flex: 1;
padding: 5px 10px;
font-size: 10px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
border: none;
border-radius: 8px;
border-radius: 5px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
@ -156,7 +119,7 @@
.banner-signup-btn:hover {
background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%);
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4);
box-shadow: 0 2px 12px rgba(139, 92, 246, 0.35);
transform: translateY(-1px);
}
@ -166,55 +129,47 @@
.edit-triggered .banner-signup-btn:hover {
background: linear-gradient(135deg, #db2777 0%, #9333ea 100%);
box-shadow: 0 4px 20px rgba(236, 72, 153, 0.4);
box-shadow: 0 2px 12px rgba(236, 72, 153, 0.35);
}
.banner-dismiss-btn {
position: absolute;
top: 4px;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
width: 18px;
height: 18px;
padding: 0;
color: #808090;
background: transparent;
background: rgba(255, 255, 255, 0.08);
border: none;
border-radius: 6px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
z-index: 1;
}
.banner-dismiss-btn svg {
width: 10px;
height: 10px;
}
.banner-dismiss-btn:hover {
color: #f0f0f0;
background: rgba(255, 255, 255, 0.1);
}
.banner-expand-btn {
padding: 6px 12px;
font-size: 12px;
color: #8b5cf6;
background: transparent;
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.banner-expand-btn:hover {
background: rgba(139, 92, 246, 0.1);
border-color: rgba(139, 92, 246, 0.5);
background: rgba(255, 255, 255, 0.15);
}
.banner-edit-notice {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
gap: 4px;
padding: 5px 10px;
background: rgba(236, 72, 153, 0.1);
border-top: 1px solid rgba(236, 72, 153, 0.2);
border-radius: 0 0 16px 16px;
font-size: 13px;
border-radius: 0 0 8px 8px;
font-size: 9px;
color: #f472b6;
}
@ -264,8 +219,8 @@
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-color: rgba(139, 92, 246, 0.2);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.1),
0 0 40px rgba(139, 92, 246, 0.1);
0 8px 24px rgba(0, 0, 0, 0.08),
0 0 16px rgba(139, 92, 246, 0.08);
}
.banner-headline {
@ -276,48 +231,48 @@
color: #1e1e2e;
}
.banner-summary,
.banner-details p,
.cryptid-benefits li {
.banner-summary {
color: #606080;
}
.banner-dismiss-btn {
color: #606080;
background: rgba(0, 0, 0, 0.05);
}
.banner-dismiss-btn:hover {
color: #2d2d44;
background: rgba(0, 0, 0, 0.05);
background: rgba(0, 0, 0, 0.1);
}
}
/* Responsive adjustments */
@media (max-width: 640px) {
.anonymous-viewer-banner {
bottom: 10px;
max-width: none;
width: calc(100% - 20px);
border-radius: 12px;
top: 56px;
right: 8px;
max-width: 180px;
}
.banner-content {
flex-direction: column;
padding: 16px;
padding: 8px;
}
.banner-icon {
width: 40px;
height: 40px;
width: 20px;
height: 20px;
}
.banner-actions {
flex-direction: row;
width: 100%;
margin-top: 12px;
.banner-icon svg {
width: 12px;
height: 12px;
}
.banner-signup-btn {
flex: 1;
.banner-headline {
font-size: 10px;
}
.banner-summary {
font-size: 9px;
}
}

View File

@ -5,6 +5,10 @@ import { loadSession, saveSession, clearStoredSession } from './sessionPersisten
export class AuthService {
/**
* Initialize the authentication state
*
* IMPORTANT: Having crypto keys stored on device does NOT mean the user is logged in.
* Keys persist after logout for potential re-authentication. Only the session's
* `authed` flag determines if a user is currently authenticated.
*/
static async initialize(): Promise<{
session: Session;
@ -13,8 +17,12 @@ export class AuthService {
const storedSession = loadSession();
let session: Session;
if (storedSession && storedSession.authed && storedSession.username) {
// Restore existing session
// Only restore session if ALL conditions are met:
// 1. Session exists in storage
// 2. Session has authed=true
// 3. Session has a username
if (storedSession && storedSession.authed === true && storedSession.username) {
// Restore existing authenticated session
session = {
username: storedSession.username,
authed: true,
@ -23,14 +31,18 @@ export class AuthService {
obsidianVaultPath: storedSession.obsidianVaultPath,
obsidianVaultName: storedSession.obsidianVaultName
};
console.log('🔐 Restored authenticated session for:', storedSession.username);
} else {
// No stored session
// No valid session - user is anonymous
// Note: User may still have crypto keys stored from previous sessions,
// but that doesn't mean they're logged in
session = {
username: '',
authed: false,
loading: false,
backupCreated: null
};
console.log('🔐 No valid session found - user is anonymous');
}
return { session };

View File

@ -42,18 +42,25 @@ export const saveSession = (session: Session): boolean => {
*/
export const loadSession = (): StoredSession | null => {
if (typeof window === 'undefined') return null;
try {
const stored = localStorage.getItem(SESSION_STORAGE_KEY);
if (!stored) {
console.log('🔐 loadSession: No stored session found');
return null;
}
const parsed = JSON.parse(stored) as StoredSession;
console.log('🔐 loadSession: Found stored session:', {
username: parsed.username,
authed: parsed.authed,
timestamp: new Date(parsed.timestamp).toISOString()
});
// Check if session is not too old (7 days)
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
if (Date.now() - parsed.timestamp > maxAge) {
console.log('🔐 loadSession: Session expired, removing');
localStorage.removeItem(SESSION_STORAGE_KEY);
return null;
}

69
src/lib/miroApiKey.ts Normal file
View File

@ -0,0 +1,69 @@
/**
* Miro API Key Management
*
* Stores the user's Miro API token in localStorage (encrypted with their CryptID)
*/
const MIRO_API_KEY_PREFIX = 'miro_api_key_';
/**
* Save Miro API key for a user
*/
export function saveMiroApiKey(apiKey: string, username: string): void {
if (!username) return;
localStorage.setItem(`${MIRO_API_KEY_PREFIX}${username}`, apiKey);
}
/**
* Get Miro API key for a user
*/
export function getMiroApiKey(username: string): string | null {
if (!username) return null;
return localStorage.getItem(`${MIRO_API_KEY_PREFIX}${username}`);
}
/**
* Remove Miro API key for a user
*/
export function removeMiroApiKey(username: string): void {
if (!username) return;
localStorage.removeItem(`${MIRO_API_KEY_PREFIX}${username}`);
}
/**
* Check if Miro API key is configured for a user
*/
export function isMiroApiKeyConfigured(username: string): boolean {
return !!getMiroApiKey(username);
}
/**
* Extract board ID from Miro URL
* Supports:
* - https://miro.com/app/board/uXjVLxxxxxx=/
* - https://miro.com/app/board/uXjVLxxxxxx=/?share_link_id=xxxxx
* - Board ID directly: uXjVLxxxxxx=
*/
export function extractMiroBoardId(urlOrId: string): string | null {
if (!urlOrId) return null;
// Direct board ID (base64-like ending with =)
if (/^[a-zA-Z0-9+/=_-]+=$/.test(urlOrId.trim())) {
return urlOrId.trim();
}
// Full URL pattern
const urlMatch = urlOrId.match(/miro\.com\/app\/board\/([a-zA-Z0-9+/=_-]+=)/);
if (urlMatch) {
return urlMatch[1];
}
return null;
}
/**
* Validate Miro URL or board ID
*/
export function isValidMiroBoardUrl(urlOrId: string): boolean {
return extractMiroBoardId(urlOrId) !== null;
}

View File

@ -0,0 +1,198 @@
/**
* Asset Migration Service
*
* Downloads images from Miro and re-uploads them to the local asset store
* This ensures images persist even if Miro access is lost
*/
import { WORKER_URL } from '../../constants/workerUrl'
import { uniqueId } from 'tldraw'
export interface AssetMigrationResult {
originalUrl: string
newUrl: string
success: boolean
error?: string
}
/**
* Download an image from a URL and return as Blob
*/
async function downloadImage(url: string): Promise<Blob> {
// Use a CORS proxy if needed for Miro URLs
let fetchUrl = url
// Miro images might need proxy
if (url.includes('miro.medium.com') || url.includes('miro.com')) {
// Try direct fetch first, then proxy if it fails
try {
const response = await fetch(url, { mode: 'cors' })
if (response.ok) {
return await response.blob()
}
} catch (e) {
// Fall through to proxy
}
// Use our worker as a proxy
fetchUrl = `${WORKER_URL}/proxy?url=${encodeURIComponent(url)}`
}
const response = await fetch(fetchUrl)
if (!response.ok) {
throw new Error(`Failed to download image: ${response.statusText}`)
}
return await response.blob()
}
/**
* Upload a blob to the asset store
*/
async function uploadToAssetStore(blob: Blob, filename: string): Promise<string> {
const id = uniqueId()
const safeName = `${id}-${filename}`.replace(/[^a-zA-Z0-9.]/g, '-')
const url = `${WORKER_URL}/uploads/${safeName}`
const response = await fetch(url, {
method: 'POST',
body: blob,
})
if (!response.ok) {
throw new Error(`Failed to upload asset: ${response.statusText}`)
}
return url
}
/**
* Extract filename from URL
*/
function extractFilename(url: string): string {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const parts = pathname.split('/')
const filename = parts[parts.length - 1] || 'image.png'
return filename.split('?')[0] // Remove query params
} catch {
return 'image.png'
}
}
/**
* Migrate a single asset from Miro to local storage
*/
export async function migrateAsset(originalUrl: string): Promise<AssetMigrationResult> {
try {
// Download the image
const blob = await downloadImage(originalUrl)
// Get a reasonable filename
const filename = extractFilename(originalUrl)
// Upload to our asset store
const newUrl = await uploadToAssetStore(blob, filename)
return {
originalUrl,
newUrl,
success: true,
}
} catch (error) {
console.error(`Failed to migrate asset ${originalUrl}:`, error)
return {
originalUrl,
newUrl: originalUrl, // Fall back to original
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
/**
* Migrate multiple assets in parallel with rate limiting
*/
export async function migrateAssets(
urls: string[],
options: {
concurrency?: number
onProgress?: (completed: number, total: number) => void
} = {}
): Promise<Map<string, AssetMigrationResult>> {
const { concurrency = 3, onProgress } = options
const results = new Map<string, AssetMigrationResult>()
// Deduplicate URLs
const uniqueUrls = [...new Set(urls)]
let completed = 0
// Process in batches
for (let i = 0; i < uniqueUrls.length; i += concurrency) {
const batch = uniqueUrls.slice(i, i + concurrency)
const batchResults = await Promise.all(
batch.map(async (url) => {
const result = await migrateAsset(url)
completed++
onProgress?.(completed, uniqueUrls.length)
return { url, result }
})
)
for (const { url, result } of batchResults) {
results.set(url, result)
}
}
return results
}
/**
* Update asset references in shapes with migrated URLs
*/
export function updateAssetReferences(
assets: any[],
migrationResults: Map<string, AssetMigrationResult>
): any[] {
return assets.map((asset) => {
const originalUrl = asset.props?.src || asset.meta?.originalUrl
if (!originalUrl) return asset
const migration = migrationResults.get(originalUrl)
if (!migration || !migration.success) return asset
return {
...asset,
props: {
...asset.props,
src: migration.newUrl,
},
meta: {
...asset.meta,
originalUrl,
migratedAt: new Date().toISOString(),
},
}
})
}
/**
* Extract all image URLs from Miro objects
*/
export function extractImageUrls(objects: any[]): string[] {
const urls: string[] = []
for (const obj of objects) {
if (obj.type === 'image' && obj.url) {
urls.push(obj.url)
}
// Also check for embedded images in other object types
if (obj.style?.backgroundImageUrl) {
urls.push(obj.style.backgroundImageUrl)
}
}
return urls
}

View File

@ -0,0 +1,483 @@
/**
* Miro to tldraw Converter
*
* Converts Miro board objects to tldraw shapes
*/
import { createShapeId, TLShapeId, AssetRecordType } from 'tldraw'
import {
MiroBoardObject,
MiroStickyNote,
MiroText,
MiroShape,
MiroImage,
MiroFrame,
MiroConnector,
MiroCard,
MIRO_TO_TLDRAW_COLORS,
MIRO_TO_TLDRAW_GEO,
} from './types'
// Default dimensions for shapes without explicit size
const DEFAULT_NOTE_SIZE = 200
const DEFAULT_SHAPE_SIZE = 100
const DEFAULT_TEXT_WIDTH = 300
const DEFAULT_FRAME_SIZE = 800
/**
* Strip HTML tags and decode entities from Miro content
*/
function stripHtml(html: string): string {
if (!html) return ''
// Remove HTML tags
let text = html.replace(/<[^>]*>/g, '')
// Decode common HTML entities
text = text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code)))
return text.trim()
}
/**
* Convert Miro color to tldraw color
*/
function convertColor(miroColor: string | undefined): string {
if (!miroColor) return 'yellow'
// Check direct mapping
const normalized = miroColor.toLowerCase().replace(/[_-]/g, '_')
if (MIRO_TO_TLDRAW_COLORS[normalized]) {
return MIRO_TO_TLDRAW_COLORS[normalized]
}
// Check hex mapping
if (miroColor.startsWith('#')) {
const hexLower = miroColor.toLowerCase()
if (MIRO_TO_TLDRAW_COLORS[hexLower]) {
return MIRO_TO_TLDRAW_COLORS[hexLower]
}
}
// Default fallback
return 'yellow'
}
/**
* Convert Miro text alignment to tldraw alignment
*/
function convertAlign(miroAlign: string | undefined): 'start' | 'middle' | 'end' {
switch (miroAlign) {
case 'left':
return 'start'
case 'center':
return 'middle'
case 'right':
return 'end'
default:
return 'middle'
}
}
/**
* Convert Miro vertical alignment to tldraw vertical alignment
*/
function convertVerticalAlign(miroAlign: string | undefined): 'start' | 'middle' | 'end' {
switch (miroAlign) {
case 'top':
return 'start'
case 'middle':
return 'middle'
case 'bottom':
return 'end'
default:
return 'middle'
}
}
/**
* Generate a unique shape ID from Miro ID
*/
function generateShapeId(miroId: string): TLShapeId {
return createShapeId(`miro-${miroId}`)
}
/**
* Generate a unique asset ID from Miro ID
*/
function generateAssetId(miroId: string): string {
return AssetRecordType.createId(`miro-${miroId}`)
}
/**
* Convert Miro sticky note to tldraw note shape
*/
export function convertStickyNote(item: MiroStickyNote, offset = { x: 0, y: 0 }): any {
const text = stripHtml(item.content)
const color = convertColor(item.style?.fillColor)
return {
id: generateShapeId(item.id),
type: 'note',
x: item.x + offset.x - (item.width || DEFAULT_NOTE_SIZE) / 2,
y: item.y + offset.y - (item.height || DEFAULT_NOTE_SIZE) / 2,
rotation: (item.rotation || 0) * (Math.PI / 180),
props: {
color,
size: 'm',
font: 'sans',
align: convertAlign(item.style?.textAlign),
verticalAlign: convertVerticalAlign(item.style?.textAlignVertical),
growY: 0,
url: '',
scale: 1,
richText: {
type: 'doc',
content: [
{
type: 'paragraph',
content: text ? [{ type: 'text', text }] : [],
},
],
},
},
}
}
/**
* Convert Miro text to tldraw text shape
*/
export function convertText(item: MiroText, offset = { x: 0, y: 0 }): any {
const text = stripHtml(item.content)
const color = convertColor(item.style?.fillColor)
return {
id: generateShapeId(item.id),
type: 'text',
x: item.x + offset.x - (item.width || DEFAULT_TEXT_WIDTH) / 2,
y: item.y + offset.y - (item.height || 50) / 2,
rotation: (item.rotation || 0) * (Math.PI / 180),
props: {
color,
size: 'm',
font: 'sans',
textAlign: convertAlign(item.style?.textAlign),
w: item.width || DEFAULT_TEXT_WIDTH,
scale: 1,
autoSize: true,
richText: {
type: 'doc',
content: [
{
type: 'paragraph',
content: text ? [{ type: 'text', text }] : [],
},
],
},
},
}
}
/**
* Convert Miro shape to tldraw geo shape
*/
export function convertShape(item: MiroShape, offset = { x: 0, y: 0 }): any {
const text = stripHtml(item.content || '')
const geo = MIRO_TO_TLDRAW_GEO[item.shape] || 'rectangle'
const color = convertColor(item.style?.fillColor || item.style?.borderColor)
// Determine fill style based on Miro's fill opacity
let fill: 'none' | 'semi' | 'solid' | 'pattern' = 'none'
if (item.style?.fillOpacity !== undefined) {
if (item.style.fillOpacity > 0.7) fill = 'solid'
else if (item.style.fillOpacity > 0.3) fill = 'semi'
else if (item.style.fillOpacity > 0) fill = 'pattern'
} else if (item.style?.fillColor) {
fill = 'solid'
}
return {
id: generateShapeId(item.id),
type: 'geo',
x: item.x + offset.x - (item.width || DEFAULT_SHAPE_SIZE) / 2,
y: item.y + offset.y - (item.height || DEFAULT_SHAPE_SIZE) / 2,
rotation: (item.rotation || 0) * (Math.PI / 180),
props: {
geo,
w: item.width || DEFAULT_SHAPE_SIZE,
h: item.height || DEFAULT_SHAPE_SIZE,
color,
labelColor: color,
fill,
dash: 'solid',
size: 'm',
font: 'sans',
align: convertAlign(item.style?.textAlign),
verticalAlign: convertVerticalAlign(item.style?.textAlignVertical),
growY: 0,
url: '',
scale: 1,
richText: {
type: 'doc',
content: [
{
type: 'paragraph',
content: text ? [{ type: 'text', text }] : [],
},
],
},
},
}
}
/**
* Convert Miro image to tldraw image shape
* Returns both shape and asset records
*/
export function convertImage(
item: MiroImage,
offset = { x: 0, y: 0 }
): { shape: any; asset: any } {
const assetId = generateAssetId(item.id)
const asset = {
id: assetId,
type: 'image',
typeName: 'asset',
props: {
name: item.title || 'Miro Image',
src: item.url, // Will be replaced after migration
w: item.width || 300,
h: item.height || 200,
mimeType: 'image/png',
isAnimated: false,
},
meta: {
miroId: item.id,
originalUrl: item.url,
},
}
const shape = {
id: generateShapeId(item.id),
type: 'image',
x: item.x + offset.x - (item.width || 300) / 2,
y: item.y + offset.y - (item.height || 200) / 2,
rotation: (item.rotation || 0) * (Math.PI / 180),
props: {
w: item.width || 300,
h: item.height || 200,
assetId,
url: '', // Deprecated but required
playing: true,
crop: null,
flipX: false,
flipY: false,
altText: item.title || '',
},
}
return { shape, asset }
}
/**
* Convert Miro frame to tldraw frame shape
*/
export function convertFrame(item: MiroFrame, offset = { x: 0, y: 0 }): any {
return {
id: generateShapeId(item.id),
type: 'frame',
x: item.x + offset.x - (item.width || DEFAULT_FRAME_SIZE) / 2,
y: item.y + offset.y - (item.height || DEFAULT_FRAME_SIZE) / 2,
rotation: (item.rotation || 0) * (Math.PI / 180),
props: {
w: item.width || DEFAULT_FRAME_SIZE,
h: item.height || DEFAULT_FRAME_SIZE,
name: item.title || 'Frame',
color: convertColor(item.style?.fillColor),
},
}
}
/**
* Convert Miro connector to tldraw arrow shape
*/
export function convertConnector(
item: MiroConnector,
objectsById: Map<string, MiroBoardObject>,
offset = { x: 0, y: 0 }
): any | null {
// Get start and end positions
let startX = item.x + offset.x
let startY = item.y + offset.y
let endX = startX + 100
let endY = startY
// If connected to items, calculate positions
if (item.startItem?.id) {
const startObj = objectsById.get(item.startItem.id)
if (startObj) {
startX = startObj.x + offset.x
startY = startObj.y + offset.y
}
}
if (item.endItem?.id) {
const endObj = objectsById.get(item.endItem.id)
if (endObj) {
endX = endObj.x + offset.x
endY = endObj.y + offset.y
}
}
// Get label text if any
const text = item.captions?.[0]?.content ? stripHtml(item.captions[0].content) : ''
return {
id: generateShapeId(item.id),
type: 'arrow',
x: Math.min(startX, endX),
y: Math.min(startY, endY),
rotation: 0,
props: {
color: convertColor(item.style?.strokeColor),
labelColor: 'black',
fill: 'none',
dash: 'solid',
size: 'm',
arrowheadStart: item.style?.startStrokeCap === 'none' ? 'none' : 'none',
arrowheadEnd: item.style?.endStrokeCap === 'none' ? 'none' : 'arrow',
font: 'sans',
start: { x: startX - Math.min(startX, endX), y: startY - Math.min(startY, endY) },
end: { x: endX - Math.min(startX, endX), y: endY - Math.min(startY, endY) },
bend: 0,
text,
labelPosition: 0.5,
scale: 1,
kind: 'arc',
elbowMidPoint: 0.5,
},
}
}
/**
* Convert Miro card to tldraw geo shape (rectangle with text)
*/
export function convertCard(item: MiroCard, offset = { x: 0, y: 0 }): any {
const title = item.title || ''
const description = item.description ? `\n${item.description}` : ''
const text = stripHtml(title + description)
return {
id: generateShapeId(item.id),
type: 'geo',
x: item.x + offset.x - (item.width || 200) / 2,
y: item.y + offset.y - (item.height || 150) / 2,
rotation: (item.rotation || 0) * (Math.PI / 180),
props: {
geo: 'rectangle',
w: item.width || 200,
h: item.height || 150,
color: 'light-blue',
labelColor: 'black',
fill: 'solid',
dash: 'solid',
size: 'm',
font: 'sans',
align: 'start',
verticalAlign: 'start',
growY: 0,
url: '',
scale: 1,
richText: {
type: 'doc',
content: [
{
type: 'paragraph',
content: text ? [{ type: 'text', text }] : [],
},
],
},
},
}
}
/**
* Main converter function - converts all Miro objects to tldraw shapes
*/
export function convertMiroBoardToTldraw(
objects: MiroBoardObject[],
offset = { x: 0, y: 0 }
): { shapes: any[]; assets: any[]; skipped: string[] } {
const shapes: any[] = []
const assets: any[] = []
const skipped: string[] = []
// Create a map for quick lookup (needed for connectors)
const objectsById = new Map<string, MiroBoardObject>()
objects.forEach((obj) => objectsById.set(obj.id, obj))
for (const obj of objects) {
try {
switch (obj.type) {
case 'sticky_note':
shapes.push(convertStickyNote(obj as MiroStickyNote, offset))
break
case 'text':
shapes.push(convertText(obj as MiroText, offset))
break
case 'shape':
shapes.push(convertShape(obj as MiroShape, offset))
break
case 'image': {
const { shape, asset } = convertImage(obj as MiroImage, offset)
shapes.push(shape)
assets.push(asset)
break
}
case 'frame':
shapes.push(convertFrame(obj as MiroFrame, offset))
break
case 'connector': {
const arrow = convertConnector(obj as MiroConnector, objectsById, offset)
if (arrow) shapes.push(arrow)
break
}
case 'card':
shapes.push(convertCard(obj as MiroCard, offset))
break
// Unsupported types - log and skip
case 'app_card':
case 'embed':
case 'document':
case 'kanban':
case 'mindmap':
case 'table':
skipped.push(`${obj.type} (id: ${obj.id})`)
break
default:
skipped.push(`unknown type '${obj.type}' (id: ${obj.id})`)
}
} catch (error) {
console.error(`Error converting Miro object ${obj.id}:`, error)
skipped.push(`${obj.type} (id: ${obj.id}) - conversion error`)
}
}
return { shapes, assets, skipped }
}

236
src/lib/miroImport/index.ts Normal file
View File

@ -0,0 +1,236 @@
/**
* Miro Import Service
*
* Main entry point for importing Miro boards into tldraw
*
* Usage:
*
* ```ts
* import { importMiroBoard, importMiroJson } from '@/lib/miroImport'
*
* // Import from Miro URL (requires backend API)
* const result = await importMiroBoard({
* boardUrl: 'https://miro.com/app/board/xxxxx=/',
* migrateAssets: true,
* })
*
* // Import from exported JSON file
* const result = await importMiroJson(jsonString, { migrateAssets: true })
*
* // Then create shapes in tldraw
* editor.createShapes(result.shapes)
* for (const asset of result.assets) {
* editor.createAssets([asset])
* }
* ```
*/
export * from './types'
export * from './converter'
export * from './scraper'
export * from './assetMigration'
import type { MiroImportOptions, MiroImportResult, MiroBoardObject } from './types'
import { convertMiroBoardToTldraw } from './converter'
import { fetchMiroBoardData, parseMiroExportFile } from './scraper'
import {
extractImageUrls,
migrateAssets,
updateAssetReferences,
} from './assetMigration'
/**
* Import a Miro board from URL
* Requires a backend API endpoint for scraping
*/
export async function importMiroBoard(
options: MiroImportOptions,
callbacks?: {
onProgress?: (stage: string, progress: number) => void
}
): Promise<MiroImportResult> {
const { boardUrl, token, frameNames, migrateAssets: shouldMigrate = true, offset = { x: 0, y: 0 } } = options
const { onProgress } = callbacks || {}
const errors: string[] = []
try {
// Stage 1: Fetch board data
onProgress?.('Fetching board data...', 0.1)
const boardData = await fetchMiroBoardData(boardUrl, { token, frameNames })
if (!boardData.objects || boardData.objects.length === 0) {
return {
success: false,
shapesCreated: 0,
assetsUploaded: 0,
errors: ['No objects found in Miro board'],
shapes: [],
assets: [],
}
}
// Stage 2: Convert to tldraw shapes
onProgress?.('Converting shapes...', 0.3)
const { shapes, assets, skipped } = convertMiroBoardToTldraw(boardData.objects, offset)
if (skipped.length > 0) {
errors.push(`Skipped ${skipped.length} unsupported items: ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? '...' : ''}`)
}
// Stage 3: Migrate assets (if enabled)
let finalAssets = assets
let assetsUploaded = 0
if (shouldMigrate && assets.length > 0) {
onProgress?.('Migrating images...', 0.5)
const imageUrls = assets
.map((a) => a.props?.src || a.meta?.originalUrl)
.filter((url): url is string => !!url)
const migrationResults = await migrateAssets(imageUrls, {
onProgress: (completed, total) => {
const progress = 0.5 + (completed / total) * 0.4
onProgress?.(`Migrating images (${completed}/${total})...`, progress)
},
})
finalAssets = updateAssetReferences(assets, migrationResults)
assetsUploaded = [...migrationResults.values()].filter((r) => r.success).length
const failedMigrations = [...migrationResults.values()].filter((r) => !r.success)
if (failedMigrations.length > 0) {
errors.push(`Failed to migrate ${failedMigrations.length} images`)
}
}
onProgress?.('Complete!', 1)
return {
success: true,
shapesCreated: shapes.length,
assetsUploaded,
errors,
shapes,
assets: finalAssets,
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
success: false,
shapesCreated: 0,
assetsUploaded: 0,
errors: [message],
shapes: [],
assets: [],
}
}
}
/**
* Import from a Miro JSON export file
* Use this when you have a pre-exported JSON from miro-export CLI
*/
export async function importMiroJson(
jsonString: string,
options: {
migrateAssets?: boolean
offset?: { x: number; y: number }
} = {},
callbacks?: {
onProgress?: (stage: string, progress: number) => void
}
): Promise<MiroImportResult> {
const { migrateAssets: shouldMigrate = true, offset = { x: 0, y: 0 } } = options
const { onProgress } = callbacks || {}
const errors: string[] = []
try {
// Parse JSON
onProgress?.('Parsing JSON...', 0.1)
const boardData = parseMiroExportFile(jsonString)
if (!boardData.objects || boardData.objects.length === 0) {
return {
success: false,
shapesCreated: 0,
assetsUploaded: 0,
errors: ['No objects found in JSON file'],
shapes: [],
assets: [],
}
}
// Convert to tldraw shapes
onProgress?.('Converting shapes...', 0.3)
const { shapes, assets, skipped } = convertMiroBoardToTldraw(boardData.objects, offset)
if (skipped.length > 0) {
errors.push(`Skipped ${skipped.length} unsupported items`)
}
// Migrate assets if enabled
let finalAssets = assets
let assetsUploaded = 0
if (shouldMigrate && assets.length > 0) {
onProgress?.('Migrating images...', 0.5)
const imageUrls = assets
.map((a) => a.props?.src || a.meta?.originalUrl)
.filter((url): url is string => !!url)
const migrationResults = await migrateAssets(imageUrls, {
onProgress: (completed, total) => {
const progress = 0.5 + (completed / total) * 0.4
onProgress?.(`Migrating images (${completed}/${total})...`, progress)
},
})
finalAssets = updateAssetReferences(assets, migrationResults)
assetsUploaded = [...migrationResults.values()].filter((r) => r.success).length
}
onProgress?.('Complete!', 1)
return {
success: true,
shapesCreated: shapes.length,
assetsUploaded,
errors,
shapes,
assets: finalAssets,
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
success: false,
shapesCreated: 0,
assetsUploaded: 0,
errors: [message],
shapes: [],
assets: [],
}
}
}
/**
* Direct conversion without asset migration
* For quick imports or when assets don't need to be migrated
*/
export function convertMiroObjects(
objects: MiroBoardObject[],
offset = { x: 0, y: 0 }
): { shapes: any[]; assets: any[]; skipped: string[] } {
return convertMiroBoardToTldraw(objects, offset)
}
/**
* Validate a Miro URL
*/
export function isValidMiroUrl(url: string): boolean {
return /miro\.com\/app\/board\/[a-zA-Z0-9+/=_-]+=/i.test(url) ||
/^[a-zA-Z0-9+/=_-]+=$/.test(url)
}

View File

@ -0,0 +1,139 @@
/**
* Miro Board Scraper
*
* Extracts board data from Miro using browser automation (Puppeteer)
* This runs on a backend service, not in the browser.
*
* For frontend use, see the API endpoint that calls this.
*/
import type { MiroBoardObject, MiroBoardExport } from './types'
/**
* Extract board ID from various Miro URL formats
* Supports:
* - https://miro.com/app/board/uXjVLxxxxxx=/
* - https://miro.com/app/board/uXjVLxxxxxx=/?share_link_id=xxxxx
* - Board ID directly: uXjVLxxxxxx=
*/
export function extractBoardId(urlOrId: string): string | null {
// Direct board ID (base64-like)
if (/^[a-zA-Z0-9+/=_-]+=$/.test(urlOrId)) {
return urlOrId
}
// Full URL pattern
const urlMatch = urlOrId.match(/miro\.com\/app\/board\/([a-zA-Z0-9+/=_-]+=)/)
if (urlMatch) {
return urlMatch[1]
}
return null
}
/**
* Parse the Miro export JSON format
* This handles both the jolle/miro-export format and Miro's internal format
*/
export function parseMiroExportJson(jsonData: any): MiroBoardObject[] {
// If it's an array, assume it's the objects directly
if (Array.isArray(jsonData)) {
return jsonData as MiroBoardObject[]
}
// If it has an 'objects' property
if (jsonData.objects && Array.isArray(jsonData.objects)) {
return jsonData.objects as MiroBoardObject[]
}
// If it has a 'data' property (some API responses)
if (jsonData.data && Array.isArray(jsonData.data)) {
return jsonData.data as MiroBoardObject[]
}
console.warn('Unknown Miro JSON format, attempting to extract objects')
return []
}
/**
* Fetch Miro board data using a proxy service
* Since Puppeteer can't run in the browser, we need a backend endpoint
*
* This function is meant to be called from the frontend and talks to our API
*/
export async function fetchMiroBoardData(
boardUrl: string,
options: {
token?: string
frameNames?: string[]
apiEndpoint?: string
} = {}
): Promise<MiroBoardExport> {
const { token, frameNames, apiEndpoint = '/api/miro/scrape' } = options
const boardId = extractBoardId(boardUrl)
if (!boardId) {
throw new Error(`Invalid Miro URL or board ID: ${boardUrl}`)
}
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
boardId,
token,
frameNames,
}),
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Failed to fetch Miro board: ${error}`)
}
const data = await response.json() as { boardName?: string; objects?: any }
return {
boardId,
boardName: data.boardName,
objects: parseMiroExportJson(data.objects || data),
exportedAt: new Date().toISOString(),
}
}
/**
* Alternative: Parse a pre-exported Miro JSON file
* Use this if you've already exported the board using miro-export CLI
*/
export function parseMiroExportFile(jsonString: string): MiroBoardExport {
try {
const data = JSON.parse(jsonString)
return {
boardId: data.boardId || 'unknown',
boardName: data.boardName,
objects: parseMiroExportJson(data),
exportedAt: data.exportedAt || new Date().toISOString(),
}
} catch (error) {
throw new Error(`Failed to parse Miro JSON: ${error}`)
}
}
/**
* Puppeteer-based scraper (for backend use only)
* This is the actual scraping logic that runs on the server
*/
export async function scrapeMiroBoardWithPuppeteer(
_boardId: string,
_options: {
token?: string
frameNames?: string[]
} = {}
): Promise<MiroBoardObject[]> {
// This is a placeholder - actual implementation requires puppeteer
// which can't be bundled for browser use
throw new Error(
'Puppeteer scraping must be done server-side. Use fetchMiroBoardData() from the frontend.'
)
}

229
src/lib/miroImport/types.ts Normal file
View File

@ -0,0 +1,229 @@
/**
* Miro Import Types
*
* Type definitions for Miro board items and tldraw shape conversion
*/
// Miro Text Alignment Types
export type MiroTextAlignment = 'left' | 'center' | 'right'
export type MiroTextVerticalAlignment = 'top' | 'middle' | 'bottom'
// Miro Base Types
export interface MiroBoardItemBase {
id: string
type: string
parentId?: string
x: number
y: number
width?: number
height?: number
rotation?: number
createdAt?: string
createdBy?: string
modifiedAt?: string
modifiedBy?: string
connectorIds?: string[]
tagIds?: string[]
}
// Miro Style Types
export interface MiroStickyNoteStyle {
fillColor: string
textAlign: MiroTextAlignment
textAlignVertical: MiroTextVerticalAlignment
}
export interface MiroShapeStyle {
fillColor?: string
fillOpacity?: number
borderColor?: string
borderWidth?: number
borderStyle?: string
fontFamily?: string
fontSize?: number
textAlign?: MiroTextAlignment
textAlignVertical?: MiroTextVerticalAlignment
}
export interface MiroTextStyle {
fillColor?: string
fontFamily?: string
fontSize?: number
textAlign?: MiroTextAlignment
}
// Miro Object Types
export interface MiroStickyNote extends MiroBoardItemBase {
type: 'sticky_note'
shape: 'square' | 'rectangle'
content: string // HTML content
style: MiroStickyNoteStyle
}
export interface MiroText extends MiroBoardItemBase {
type: 'text'
content: string
style: MiroTextStyle
}
export interface MiroShape extends MiroBoardItemBase {
type: 'shape'
shape: string // rectangle, circle, triangle, etc.
content?: string
style: MiroShapeStyle
}
export interface MiroImage extends MiroBoardItemBase {
type: 'image'
url: string
title?: string
}
export interface MiroFrame extends MiroBoardItemBase {
type: 'frame'
title?: string
childrenIds: string[]
style?: {
fillColor?: string
}
}
export interface MiroConnector extends MiroBoardItemBase {
type: 'connector'
startItem?: { id: string }
endItem?: { id: string }
shape?: string
style?: {
strokeColor?: string
strokeWidth?: number
startStrokeCap?: string
endStrokeCap?: string
}
captions?: Array<{ content: string }>
}
export interface MiroCard extends MiroBoardItemBase {
type: 'card'
title?: string
description?: string
style?: {
cardTheme?: string
fillBackground?: boolean
}
assignee?: {
userId: string
}
dueDate?: string
}
export interface MiroEmbed extends MiroBoardItemBase {
type: 'embed'
url: string
mode?: string
previewUrl?: string
}
// Union type for all Miro objects
export type MiroBoardObject =
| MiroStickyNote
| MiroText
| MiroShape
| MiroImage
| MiroFrame
| MiroConnector
| MiroCard
| MiroEmbed
| (MiroBoardItemBase & { type: string }) // Catch-all for unknown types
// Miro board export format
export interface MiroBoardExport {
boardId: string
boardName?: string
objects: MiroBoardObject[]
exportedAt: string
}
// Import options
export interface MiroImportOptions {
/** Miro board URL or ID */
boardUrl: string
/** Optional authentication token for private boards */
token?: string
/** Specific frame names to import (empty = whole board) */
frameNames?: string[]
/** Whether to download and re-upload images to local storage */
migrateAssets?: boolean
/** Target position offset for imported shapes */
offset?: { x: number; y: number }
}
// Import result
export interface MiroImportResult {
success: boolean
shapesCreated: number
assetsUploaded: number
errors: string[]
/** The tldraw shapes ready to be created */
shapes: any[] // TLShape[]
/** Asset records to be created */
assets: any[] // TLAsset[]
}
// Color mapping from Miro to tldraw
export const MIRO_TO_TLDRAW_COLORS: Record<string, string> = {
// Miro sticky note colors
'gray': 'grey',
'light_yellow': 'yellow',
'yellow': 'yellow',
'orange': 'orange',
'light_green': 'light-green',
'green': 'green',
'dark_green': 'green',
'cyan': 'light-blue',
'light_blue': 'light-blue',
'blue': 'blue',
'dark_blue': 'blue',
'light_pink': 'light-red',
'pink': 'light-red',
'violet': 'violet',
'red': 'red',
'black': 'black',
'white': 'white',
// Hex colors (approximate mapping)
'#f5d128': 'yellow',
'#f24726': 'red',
'#ff9d48': 'orange',
'#93d275': 'light-green',
'#12cdd4': 'light-blue',
'#652cb3': 'violet',
'#808080': 'grey',
}
// Shape type mapping from Miro to tldraw geo types
export const MIRO_TO_TLDRAW_GEO: Record<string, string> = {
'rectangle': 'rectangle',
'square': 'rectangle',
'round_rectangle': 'rectangle',
'circle': 'ellipse',
'oval': 'oval',
'ellipse': 'ellipse',
'triangle': 'triangle',
'right_triangle': 'triangle',
'diamond': 'diamond',
'rhombus': 'rhombus',
'parallelogram': 'trapezoid',
'trapezoid': 'trapezoid',
'pentagon': 'pentagon',
'hexagon': 'hexagon',
'octagon': 'octagon',
'star': 'star',
'arrow_left': 'arrow-left',
'arrow_right': 'arrow-right',
'arrow_up': 'arrow-up',
'arrow_down': 'arrow-down',
'cloud': 'cloud',
'heart': 'heart',
'cross': 'x-box',
'can': 'rectangle', // No exact match
'wedge_round_rectangle_callout': 'rectangle',
}

View File

@ -35,7 +35,8 @@ const API_BASE = '/api/networking';
*/
function getCurrentUserId(): string | null {
try {
const sessionStr = localStorage.getItem('cryptid_session');
// Session is stored as 'canvas_auth_session' by sessionPersistence.ts
const sessionStr = localStorage.getItem('canvas_auth_session');
if (sessionStr) {
const session = JSON.parse(sessionStr);
if (session.authed && session.username) {

View File

@ -12,7 +12,7 @@ import { EmbedShape } from "@/shapes/EmbedShapeUtil"
import { EmbedTool } from "@/tools/EmbedTool"
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
import { MarkdownTool } from "@/tools/MarkdownTool"
import { defaultShapeUtils, defaultBindingUtils } from "tldraw"
import { defaultShapeUtils, defaultBindingUtils, defaultShapeTools } from "tldraw"
import { components } from "@/ui/components"
import { overrides } from "@/ui/overrides"
import { unfurlBookmarkUrl } from "../utils/unfurlBookmarkUrl"
@ -71,8 +71,8 @@ import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
import { GestureTool } from "@/GestureTool"
import { CmdK } from "@/CmdK"
import { setupMultiPasteHandler } from "@/utils/multiPasteHandler"
import { ConnectionStatusIndicator } from "@/components/ConnectionStatusIndicator"
import AnonymousViewerBanner from "@/components/auth/AnonymousViewerBanner"
import { ConnectionProvider } from "@/context/ConnectionContext"
import { PermissionLevel } from "@/lib/auth/types"
import "@/css/anonymous-banner.css"
@ -281,7 +281,28 @@ export function Board() {
const [permissionLoading, setPermissionLoading] = useState(true)
const [showEditPrompt, setShowEditPrompt] = useState(false)
// Fetch permission when board loads
// Track previous auth state to detect transitions (fixes React timing issue)
// Effects run AFTER render, but we need to know if auth JUST changed during this render
const prevAuthRef = useRef(session.authed)
const authJustChanged = prevAuthRef.current !== session.authed
// Counter to force Tldraw remount on every auth change
// This guarantees a fresh tldraw instance with correct read-only state
const [authChangeCount, setAuthChangeCount] = useState(0)
// Reset permission state when auth changes (ensures fresh fetch on login/logout)
useEffect(() => {
// Update the ref after render
prevAuthRef.current = session.authed
// Increment counter to force tldraw remount
setAuthChangeCount(c => c + 1)
// When auth state changes, reset permission to trigger fresh fetch
setPermission(null)
setPermissionLoading(true)
console.log('🔄 Auth changed, forcing tldraw remount. New auth state:', session.authed)
}, [session.authed])
// Fetch permission when board loads or auth changes
useEffect(() => {
let mounted = true
@ -291,6 +312,7 @@ export function Board() {
const perm = await fetchBoardPermission(roomId)
if (mounted) {
setPermission(perm)
console.log('🔐 Permission fetched:', perm)
}
} catch (error) {
console.error('Failed to fetch permission:', error)
@ -312,8 +334,46 @@ export function Board() {
}
}, [roomId, fetchBoardPermission, session.authed])
// Check if user can edit (either has edit/admin permission, or is authenticated with default edit access)
const isReadOnly = permission === 'view' || (!session.authed && !permission)
// Check if user can edit
// Authenticated users get edit access by default unless explicitly restricted to 'view'
// Unauthenticated users are always read-only
// Note: permission will be 'edit' for authenticated users by default (see AuthContext)
//
// CRITICAL: Don't restrict in these cases:
// 1. Auth is loading
// 2. Auth just changed (React effects haven't run yet, permission state is stale)
// 3. Permission is loading for authenticated users
// This prevents authenticated users from briefly seeing read-only mode which hides
// default tools (only tools with readonlyOk: true show in read-only mode)
const isReadOnly = (
session.loading ||
(session.authed && authJustChanged) || // Auth just changed, permission is stale
(session.authed && permissionLoading)
)
? false // Don't restrict while loading/transitioning - assume authenticated users can edit
: (!session.authed || permission === 'view')
// Debug logging for permission issues
console.log('🔐 Permission Debug:', {
permission,
permissionLoading,
sessionAuthed: session.authed,
sessionLoading: session.loading,
sessionUsername: session.username,
authJustChanged,
isReadOnly,
reason: session.loading
? 'auth loading - allowing edit temporarily'
: (session.authed && authJustChanged)
? 'auth just changed - allowing edit until effects run'
: (session.authed && permissionLoading)
? 'permission loading for authenticated user - allowing edit temporarily'
: !session.authed
? 'not authenticated - view only mode'
: permission === 'view'
? 'explicitly restricted to view-only by board admin'
: 'authenticated with edit access'
})
// Handler for when user tries to edit in read-only mode
const handleEditAttempt = () => {
@ -916,17 +976,39 @@ export function Board() {
let lastContentHash = '';
let timeoutId: NodeJS.Timeout;
let idleCallbackId: number | null = null;
const captureScreenshot = async () => {
// Don't capture if user is actively drawing (pointer is down)
// This prevents interrupting continuous drawing operations
const inputs = editor.inputs;
if (inputs.isPointing || inputs.isDragging) {
// Reschedule for later when user stops drawing
timeoutId = setTimeout(captureScreenshot, 2000);
return;
}
const currentShapes = editor.getCurrentPageShapes();
const currentContentHash = currentShapes.length > 0
const currentContentHash = currentShapes.length > 0
? currentShapes.map(shape => `${shape.id}-${shape.type}`).sort().join('|')
: '';
// Only capture if content actually changed
if (currentContentHash !== lastContentHash) {
lastContentHash = currentContentHash;
await captureBoardScreenshot(editor, roomId);
// Use requestIdleCallback to run during browser idle time
// This prevents blocking the main thread during user interactions
const doCapture = () => {
captureBoardScreenshot(editor, roomId);
};
if ('requestIdleCallback' in window) {
idleCallbackId = requestIdleCallback(doCapture, { timeout: 5000 });
} else {
// Fallback for browsers without requestIdleCallback
setTimeout(doCapture, 100);
}
}
};
@ -934,14 +1016,18 @@ export function Board() {
const unsubscribe = store.store.listen(() => {
// Clear existing timeout
if (timeoutId) clearTimeout(timeoutId);
// Set new timeout for debounced screenshot capture
timeoutId = setTimeout(captureScreenshot, 3000);
// Set new timeout for debounced screenshot capture (5 seconds instead of 3)
// Longer debounce gives users more time for continuous operations
timeoutId = setTimeout(captureScreenshot, 5000);
}, { source: "user", scope: "document" });
return () => {
unsubscribe();
if (timeoutId) clearTimeout(timeoutId);
if (idleCallbackId !== null && 'cancelIdleCallback' in window) {
cancelIdleCallback(idleCallbackId);
}
};
}, [editor, roomId, store.store]);
@ -1071,143 +1157,149 @@ export function Board() {
return (
<AutomergeHandleProvider handle={automergeHandle}>
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw
store={store.store}
user={user}
shapeUtils={[...defaultShapeUtils, ...customShapeUtils]}
tools={customTools}
components={components}
overrides={{
...overrides,
actions: (editor, actions, helpers) => {
const customActions = overrides.actions?.(editor, actions, helpers) ?? {}
return {
...actions,
...customActions,
<ConnectionProvider connectionState={connectionState} isNetworkOnline={isNetworkOnline}>
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw
key={`tldraw-${authChangeCount}-${session.authed ? 'authed' : 'anon'}-${session.username || 'guest'}`}
store={store.store}
user={user}
shapeUtils={[...defaultShapeUtils, ...customShapeUtils]}
tools={[...defaultShapeTools, ...customTools]}
components={components}
overrides={{
...overrides,
actions: (editor, actions, helpers) => {
const customActions = overrides.actions?.(editor, actions, helpers) ?? {}
return {
...actions,
...customActions,
}
}
}
}}
cameraOptions={{
zoomSteps: [
0.001, // Min zoom
0.0025,
0.005,
0.01,
0.025,
0.05,
0.1,
0.25,
0.5,
1,
2,
4,
8,
16,
32,
64, // Max zoom
],
}}
onMount={(editor) => {
setEditor(editor)
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
editor.setCurrentTool("hand")
setInitialCameraFromUrl(editor)
handleInitialPageLoad(editor)
registerPropagators(editor, [
TickPropagator,
ChangePropagator,
ClickPropagator,
])
}}
cameraOptions={{
zoomSteps: [
0.001, // Min zoom
0.0025,
0.005,
0.01,
0.025,
0.05,
0.1,
0.25,
0.5,
1,
2,
4,
8,
16,
32,
64, // Max zoom
],
}}
onMount={(editor) => {
setEditor(editor)
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
editor.setCurrentTool("hand")
setInitialCameraFromUrl(editor)
handleInitialPageLoad(editor)
registerPropagators(editor, [
TickPropagator,
ChangePropagator,
ClickPropagator,
])
// Clean up corrupted shapes that cause "No nearest point found" errors
// This typically happens with draw/line shapes that have no points
try {
const allShapes = editor.getCurrentPageShapes()
const corruptedShapeIds: TLShapeId[] = []
// Clean up corrupted shapes that cause "No nearest point found" errors
// This typically happens with draw/line shapes that have no points
try {
const allShapes = editor.getCurrentPageShapes()
const corruptedShapeIds: TLShapeId[] = []
for (const shape of allShapes) {
// Check draw and line shapes for missing/empty segments
if (shape.type === 'draw' || shape.type === 'line') {
const props = shape.props as any
// Draw shapes need segments with points
if (shape.type === 'draw') {
if (!props.segments || props.segments.length === 0) {
corruptedShapeIds.push(shape.id)
continue
for (const shape of allShapes) {
// Check draw and line shapes for missing/empty segments
if (shape.type === 'draw' || shape.type === 'line') {
const props = shape.props as any
// Draw shapes need segments with points
if (shape.type === 'draw') {
if (!props.segments || props.segments.length === 0) {
corruptedShapeIds.push(shape.id)
continue
}
// Check if all segments have no points
const hasPoints = props.segments.some((seg: any) => seg.points && seg.points.length > 0)
if (!hasPoints) {
corruptedShapeIds.push(shape.id)
}
}
// Check if all segments have no points
const hasPoints = props.segments.some((seg: any) => seg.points && seg.points.length > 0)
if (!hasPoints) {
corruptedShapeIds.push(shape.id)
}
}
// Line shapes need points
if (shape.type === 'line') {
if (!props.points || Object.keys(props.points).length === 0) {
corruptedShapeIds.push(shape.id)
// Line shapes need points
if (shape.type === 'line') {
if (!props.points || Object.keys(props.points).length === 0) {
corruptedShapeIds.push(shape.id)
}
}
}
}
if (corruptedShapeIds.length > 0) {
console.warn(`🧹 Removing ${corruptedShapeIds.length} corrupted shapes (draw/line with no points)`)
editor.deleteShapes(corruptedShapeIds)
}
} catch (error) {
console.error('Error cleaning up corrupted shapes:', error)
}
if (corruptedShapeIds.length > 0) {
console.warn(`🧹 Removing ${corruptedShapeIds.length} corrupted shapes (draw/line with no points)`)
editor.deleteShapes(corruptedShapeIds)
// Set user preferences immediately if user is authenticated
if (session.authed && session.username) {
try {
editor.user.updateUserPreferences({
id: session.username,
name: session.username,
});
} catch (error) {
console.error('Error setting initial TLDraw user preferences:', error);
}
} else {
// Set default user preferences when not authenticated
try {
editor.user.updateUserPreferences({
id: 'user-1',
name: 'User 1',
});
} catch (error) {
console.error('Error setting default TLDraw user preferences:', error);
}
}
} catch (error) {
console.error('Error cleaning up corrupted shapes:', error)
}
// Set user preferences immediately if user is authenticated
if (session.authed && session.username) {
try {
editor.user.updateUserPreferences({
id: session.username,
name: session.username,
});
} catch (error) {
console.error('Error setting initial TLDraw user preferences:', error);
}
} else {
// Set default user preferences when not authenticated
try {
editor.user.updateUserPreferences({
id: 'user-1',
name: 'User 1',
});
} catch (error) {
console.error('Error setting default TLDraw user preferences:', error);
}
}
initializeGlobalCollections(editor, collections)
// Note: User presence is configured through the useAutomergeSync hook above
// The authenticated username should appear in the people section
// MycelialIntelligence is now a permanent UI bar - no shape creation needed
initializeGlobalCollections(editor, collections)
// Note: User presence is configured through the useAutomergeSync hook above
// The authenticated username should appear in the people section
// MycelialIntelligence is now a permanent UI bar - no shape creation needed
// Set read-only mode based on permission
if (isReadOnly) {
editor.updateInstanceState({ isReadonly: true })
console.log('🔒 Board is in read-only mode for this user')
}
}}
>
<CmdK />
<PrivateWorkspaceManager />
<VisibilityChangeManager />
</Tldraw>
<ConnectionStatusIndicator
connectionState={connectionState}
isNetworkOnline={isNetworkOnline}
/>
{/* Anonymous viewer banner - show for unauthenticated users or when edit was attempted */}
{(!session.authed || showEditPrompt) && (
<AnonymousViewerBanner
onAuthenticated={handleAuthenticated}
triggeredByEdit={showEditPrompt}
/>
)}
</div>
// Set read-only mode based on auth state
// IMPORTANT: Use session.authed directly here, not the isReadOnly variable
// The isReadOnly variable might have stale values due to React's timing (effects run after render)
// For authenticated users, we assume editable until permission proves otherwise
// The effect that watches isReadOnly will update this if user only has 'view' permission
const initialReadOnly = !session.authed
editor.updateInstanceState({ isReadonly: initialReadOnly })
console.log('🔄 onMount: session.authed =', session.authed, ', setting isReadonly =', initialReadOnly)
console.log(initialReadOnly
? '🔒 Board is in read-only mode (not authenticated)'
: '🔓 Board is editable (authenticated)')
}}
>
<CmdK />
<PrivateWorkspaceManager />
<VisibilityChangeManager />
</Tldraw>
{/* Anonymous viewer banner - show for unauthenticated users or when edit was attempted */}
{/* Wait for auth to finish loading to avoid flash, then show if not authed or edit triggered */}
{!session.loading && (!session.authed || showEditPrompt) && (
<AnonymousViewerBanner
onAuthenticated={handleAuthenticated}
triggeredByEdit={showEditPrompt}
/>
)}
</div>
</ConnectionProvider>
</AutomergeHandleProvider>
)
}

View File

@ -6,9 +6,12 @@ import {
DefaultMainMenuContent,
useEditor,
} from "tldraw";
import { useState } from "react";
import { MiroImportDialog } from "@/components/MiroImportDialog";
export function CustomMainMenu() {
const editor = useEditor()
const [showMiroImport, setShowMiroImport] = useState(false)
const importJSON = (editor: Editor) => {
const input = document.createElement("input");
@ -727,29 +730,42 @@ export function CustomMainMenu() {
};
return (
<DefaultMainMenu>
<DefaultMainMenuContent />
<TldrawUiMenuItem
id="export"
label="Export JSON"
icon="external-link"
readonlyOk
onSelect={() => exportJSON(editor)}
<>
<DefaultMainMenu>
<DefaultMainMenuContent />
<TldrawUiMenuItem
id="export"
label="Export JSON"
icon="external-link"
readonlyOk
onSelect={() => exportJSON(editor)}
/>
<TldrawUiMenuItem
id="import"
label="Import JSON"
icon="external-link"
readonlyOk
onSelect={() => importJSON(editor)}
/>
<TldrawUiMenuItem
id="import-miro"
label="Import from Miro"
icon="external-link"
readonlyOk
onSelect={() => setShowMiroImport(true)}
/>
<TldrawUiMenuItem
id="fit-to-content"
label="Fit to Content"
icon="external-link"
readonlyOk
onSelect={() => fitToContent(editor)}
/>
</DefaultMainMenu>
<MiroImportDialog
isOpen={showMiroImport}
onClose={() => setShowMiroImport(false)}
/>
<TldrawUiMenuItem
id="import"
label="Import JSON"
icon="external-link"
readonlyOk
onSelect={() => importJSON(editor)}
/>
<TldrawUiMenuItem
id="fit-to-content"
label="Fit to Content"
icon="external-link"
readonlyOk
onSelect={() => fitToContent(editor)}
/>
</DefaultMainMenu>
</>
)
}

View File

@ -642,150 +642,156 @@ export function CustomToolbar() {
if (!isReady) return null
// Only show custom tools for authenticated users
const isAuthenticated = session.authed
return (
<>
<DefaultToolbar>
<DefaultToolbarContent />
{tools["VideoChat"] && (
<TldrawUiMenuItem
{...tools["VideoChat"]}
icon="video"
label="Video Chat"
isSelected={tools["VideoChat"].id === editor.getCurrentToolId()}
/>
{/* Custom tools - only shown when authenticated */}
{isAuthenticated && (
<>
{tools["VideoChat"] && (
<TldrawUiMenuItem
{...tools["VideoChat"]}
icon="video"
label="Video Chat"
isSelected={tools["VideoChat"].id === editor.getCurrentToolId()}
/>
)}
{tools["ChatBox"] && (
<TldrawUiMenuItem
{...tools["ChatBox"]}
icon="chat"
label="Chat"
isSelected={tools["ChatBox"].id === editor.getCurrentToolId()}
/>
)}
{tools["Embed"] && (
<TldrawUiMenuItem
{...tools["Embed"]}
icon="embed"
label="Embed"
isSelected={tools["Embed"].id === editor.getCurrentToolId()}
/>
)}
{tools["SlideShape"] && (
<TldrawUiMenuItem
{...tools["SlideShape"]}
icon="slides"
label="Slide"
isSelected={tools["SlideShape"].id === editor.getCurrentToolId()}
/>
)}
{tools["Markdown"] && (
<TldrawUiMenuItem
{...tools["Markdown"]}
icon="markdown"
label="Markdown"
isSelected={tools["Markdown"].id === editor.getCurrentToolId()}
/>
)}
{tools["MycrozineTemplate"] && (
<TldrawUiMenuItem
{...tools["MycrozineTemplate"]}
icon="mycrozinetemplate"
label="MycrozineTemplate"
isSelected={
tools["MycrozineTemplate"].id === editor.getCurrentToolId()
}
/>
)}
{tools["Prompt"] && (
<TldrawUiMenuItem
{...tools["Prompt"]}
icon="prompt"
label="LLM Prompt"
isSelected={tools["Prompt"].id === editor.getCurrentToolId()}
/>
)}
{tools["ObsidianNote"] && (
<TldrawUiMenuItem
{...tools["ObsidianNote"]}
icon="file-text"
label="Obsidian Note"
isSelected={tools["ObsidianNote"].id === editor.getCurrentToolId()}
/>
)}
{tools["Transcription"] && (
<TldrawUiMenuItem
{...tools["Transcription"]}
icon="microphone"
label="Transcription"
isSelected={tools["Transcription"].id === editor.getCurrentToolId()}
/>
)}
{tools["Holon"] && (
<TldrawUiMenuItem
{...tools["Holon"]}
icon="globe"
label="Holon"
isSelected={tools["Holon"].id === editor.getCurrentToolId()}
/>
)}
{tools["FathomMeetings"] && (
<TldrawUiMenuItem
{...tools["FathomMeetings"]}
icon="calendar"
label="Fathom Meetings"
isSelected={tools["FathomMeetings"].id === editor.getCurrentToolId()}
/>
)}
{tools["ImageGen"] && (
<TldrawUiMenuItem
{...tools["ImageGen"]}
icon="image"
label="Image Generation"
isSelected={tools["ImageGen"].id === editor.getCurrentToolId()}
/>
)}
{tools["VideoGen"] && (
<TldrawUiMenuItem
{...tools["VideoGen"]}
icon="video"
label="Video Generation"
isSelected={tools["VideoGen"].id === editor.getCurrentToolId()}
/>
)}
{tools["Multmux"] && (
<TldrawUiMenuItem
{...tools["Multmux"]}
icon="terminal"
label="Terminal"
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
/>
)}
{tools["Map"] && (
<TldrawUiMenuItem
{...tools["Map"]}
icon="geo-globe"
label="Map"
isSelected={tools["Map"].id === editor.getCurrentToolId()}
/>
)}
{/* Refresh All ObsNotes Button */}
{(() => {
const allShapes = editor.getCurrentPageShapes()
const obsNoteShapes = allShapes.filter(shape => shape.type === 'ObsNote')
return obsNoteShapes.length > 0 && (
<TldrawUiMenuItem
id="refresh-all-obsnotes"
icon="refresh-cw"
label="Refresh All Notes"
onSelect={() => {
const event = new CustomEvent('refresh-all-obsnotes')
window.dispatchEvent(event)
}}
/>
)
})()}
</>
)}
{tools["ChatBox"] && (
<TldrawUiMenuItem
{...tools["ChatBox"]}
icon="chat"
label="Chat"
isSelected={tools["ChatBox"].id === editor.getCurrentToolId()}
/>
)}
{tools["Embed"] && (
<TldrawUiMenuItem
{...tools["Embed"]}
icon="embed"
label="Embed"
isSelected={tools["Embed"].id === editor.getCurrentToolId()}
/>
)}
{tools["SlideShape"] && (
<TldrawUiMenuItem
{...tools["SlideShape"]}
icon="slides"
label="Slide"
isSelected={tools["SlideShape"].id === editor.getCurrentToolId()}
/>
)}
{tools["Markdown"] && (
<TldrawUiMenuItem
{...tools["Markdown"]}
icon="markdown"
label="Markdown"
isSelected={tools["Markdown"].id === editor.getCurrentToolId()}
/>
)}
{tools["MycrozineTemplate"] && (
<TldrawUiMenuItem
{...tools["MycrozineTemplate"]}
icon="mycrozinetemplate"
label="MycrozineTemplate"
isSelected={
tools["MycrozineTemplate"].id === editor.getCurrentToolId()
}
/>
)}
{tools["Prompt"] && (
<TldrawUiMenuItem
{...tools["Prompt"]}
icon="prompt"
label="LLM Prompt"
isSelected={tools["Prompt"].id === editor.getCurrentToolId()}
/>
)}
{tools["ObsidianNote"] && (
<TldrawUiMenuItem
{...tools["ObsidianNote"]}
icon="file-text"
label="Obsidian Note"
isSelected={tools["ObsidianNote"].id === editor.getCurrentToolId()}
/>
)}
{tools["Transcription"] && (
<TldrawUiMenuItem
{...tools["Transcription"]}
icon="microphone"
label="Transcription"
isSelected={tools["Transcription"].id === editor.getCurrentToolId()}
/>
)}
{tools["Holon"] && (
<TldrawUiMenuItem
{...tools["Holon"]}
icon="globe"
label="Holon"
isSelected={tools["Holon"].id === editor.getCurrentToolId()}
/>
)}
{tools["FathomMeetings"] && (
<TldrawUiMenuItem
{...tools["FathomMeetings"]}
icon="calendar"
label="Fathom Meetings"
isSelected={tools["FathomMeetings"].id === editor.getCurrentToolId()}
/>
)}
{tools["ImageGen"] && (
<TldrawUiMenuItem
{...tools["ImageGen"]}
icon="image"
label="Image Generation"
isSelected={tools["ImageGen"].id === editor.getCurrentToolId()}
/>
)}
{tools["VideoGen"] && (
<TldrawUiMenuItem
{...tools["VideoGen"]}
icon="video"
label="Video Generation"
isSelected={tools["VideoGen"].id === editor.getCurrentToolId()}
/>
)}
{tools["Multmux"] && (
<TldrawUiMenuItem
{...tools["Multmux"]}
icon="terminal"
label="Terminal"
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
/>
)}
{tools["Map"] && (
<TldrawUiMenuItem
{...tools["Map"]}
icon="geo-globe"
label="Map"
isSelected={tools["Map"].id === editor.getCurrentToolId()}
/>
)}
{/* MycelialIntelligence moved to permanent floating bar */}
{/* Share Location tool removed for now */}
{/* Refresh All ObsNotes Button */}
{(() => {
const allShapes = editor.getCurrentPageShapes()
const obsNoteShapes = allShapes.filter(shape => shape.type === 'ObsNote')
return obsNoteShapes.length > 0 && (
<TldrawUiMenuItem
id="refresh-all-obsnotes"
icon="refresh-cw"
label="Refresh All Notes"
onSelect={() => {
const event = new CustomEvent('refresh-all-obsnotes')
window.dispatchEvent(event)
}}
/>
)
})()}
</DefaultToolbar>
{/* Fathom Meetings Panel */}

View File

@ -5,6 +5,7 @@ import { useWebSpeechTranscription } from "@/hooks/useWebSpeechTranscription"
import { ToolSchema } from "@/lib/toolSchema"
import { spawnTools, spawnTool } from "@/utils/toolSpawner"
import { TransformCommand } from "@/utils/selectionTransforms"
import { useConnectionStatus } from "@/context/ConnectionContext"
// Copy icon component
const CopyIcon = () => (
@ -803,9 +804,92 @@ interface ConversationMessage {
executedTransform?: TransformCommand
}
// Connection status indicator component - unobtrusive inline display
interface ConnectionStatusProps {
connectionState: 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
isNetworkOnline: boolean
isDark: boolean
}
function ConnectionStatusBadge({ connectionState, isNetworkOnline, isDark }: ConnectionStatusProps) {
// Don't show anything when fully connected and online
if (connectionState === 'connected' && isNetworkOnline) {
return null
}
const getStatusConfig = () => {
if (!isNetworkOnline) {
return {
icon: '📴',
label: 'Offline',
color: isDark ? '#a78bfa' : '#8b5cf6',
pulse: false,
}
}
switch (connectionState) {
case 'connecting':
return {
icon: '🌱',
label: 'Connecting',
color: '#f59e0b',
pulse: true,
}
case 'reconnecting':
return {
icon: '🔄',
label: 'Reconnecting',
color: '#f59e0b',
pulse: true,
}
case 'disconnected':
return {
icon: '🍄',
label: 'Local',
color: isDark ? '#a78bfa' : '#8b5cf6',
pulse: false,
}
default:
return null
}
}
const config = getStatusConfig()
if (!config) return null
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '12px',
backgroundColor: isDark ? 'rgba(139, 92, 246, 0.15)' : 'rgba(139, 92, 246, 0.1)',
border: `1px solid ${isDark ? 'rgba(139, 92, 246, 0.3)' : 'rgba(139, 92, 246, 0.2)'}`,
fontSize: '10px',
fontWeight: 500,
color: config.color,
animation: config.pulse ? 'connectionPulse 2s infinite' : undefined,
flexShrink: 0,
}}
title={!isNetworkOnline
? 'Working offline - changes saved locally and will sync when reconnected'
: connectionState === 'reconnecting'
? 'Reconnecting to server - your changes are safe'
: 'Connecting to server...'
}
>
<span style={{ fontSize: '11px' }}>{config.icon}</span>
<span>{config.label}</span>
</div>
)
}
export function MycelialIntelligenceBar() {
const editor = useEditor()
const isDark = useDarkMode()
const { connectionState, isNetworkOnline } = useConnectionStatus()
const inputRef = useRef<HTMLInputElement>(null)
const chatContainerRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
@ -838,8 +922,11 @@ export function MycelialIntelligenceBar() {
const hasTldrawDialog = document.querySelector('[data-state="open"][role="dialog"]') !== null
const hasAuthModal = document.querySelector('.auth-modal-overlay') !== null
const hasPopup = document.querySelector('.profile-popup') !== null
const hasCryptIDModal = document.querySelector('.cryptid-modal-overlay') !== null
const hasMiroModal = document.querySelector('.miro-modal-overlay') !== null
const hasObsidianModal = document.querySelector('.obsidian-browser') !== null
setIsModalOpen(hasSettingsModal || hasTldrawDialog || hasAuthModal || hasPopup)
setIsModalOpen(hasSettingsModal || hasTldrawDialog || hasAuthModal || hasPopup || hasCryptIDModal || hasMiroModal || hasObsidianModal)
}
// Initial check
@ -1351,8 +1438,9 @@ export function MycelialIntelligenceBar() {
}, [])
// Height: taller when showing suggestion chips (single tool or 2+ selected)
// Base height matches the top-right menu (~40px) for visual alignment
const showSuggestions = selectedToolInfo || (selectionInfo && selectionInfo.count > 1)
const collapsedHeight = showSuggestions ? 76 : 48
const collapsedHeight = showSuggestions ? 68 : 40
const maxExpandedHeight = isMobile ? 300 : 400
// Responsive width: full width on mobile, percentage on narrow, fixed on desktop
const barWidth = isMobile ? 'calc(100% - 20px)' : isNarrow ? 'calc(100% - 120px)' : 520
@ -1414,8 +1502,8 @@ export function MycelialIntelligenceBar() {
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
padding: '6px 10px 6px 14px',
gap: '2px',
padding: '4px 8px 4px 12px',
height: '100%',
justifyContent: 'center',
}}>
@ -1431,7 +1519,7 @@ export function MycelialIntelligenceBar() {
flexShrink: 0,
}}>
<span style={{
fontSize: '16px',
fontSize: '14px',
opacity: 0.9,
}}>
🍄🧠
@ -1487,13 +1575,20 @@ export function MycelialIntelligenceBar() {
flex: 1,
background: 'transparent',
border: 'none',
padding: '8px 4px',
fontSize: '14px',
padding: '6px 4px',
fontSize: '13px',
color: colors.inputText,
outline: 'none',
}}
/>
{/* Connection status indicator - unobtrusive */}
<ConnectionStatusBadge
connectionState={connectionState}
isNetworkOnline={isNetworkOnline}
isDark={isDark}
/>
{/* Indexing indicator */}
{isIndexing && (
<span style={{
@ -1515,8 +1610,8 @@ export function MycelialIntelligenceBar() {
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '34px',
height: '34px',
width: '28px',
height: '28px',
borderRadius: '50%',
border: 'none',
background: isRecording
@ -1549,9 +1644,9 @@ export function MycelialIntelligenceBar() {
onPointerDown={(e) => e.stopPropagation()}
disabled={!prompt.trim() || isLoading}
style={{
height: '34px',
padding: selectedToolInfo ? '0 12px' : '0 14px',
borderRadius: '17px',
height: '28px',
padding: selectedToolInfo ? '0 10px' : '0 12px',
borderRadius: '14px',
border: 'none',
background: prompt.trim() && !isLoading
? selectedToolInfo ? '#6366f1' : ACCENT_COLOR
@ -1589,8 +1684,8 @@ export function MycelialIntelligenceBar() {
}}
onPointerDown={(e) => e.stopPropagation()}
style={{
width: '34px',
height: '34px',
width: '28px',
height: '28px',
borderRadius: '50%',
border: 'none',
background: 'transparent',
@ -1687,6 +1782,12 @@ export function MycelialIntelligenceBar() {
}}>
<span style={{ fontStyle: 'italic', opacity: 0.85 }}>ask your mycelial intelligence anything about this workspace</span>
</span>
{/* Connection status in expanded header */}
<ConnectionStatusBadge
connectionState={connectionState}
isNetworkOnline={isNetworkOnline}
isDark={isDark}
/>
{isIndexing && (
<span style={{
color: colors.textMuted,
@ -2087,6 +2188,10 @@ export function MycelialIntelligenceBar() {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
@keyframes connectionPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
`}</style>
</div>
)

View File

@ -1,16 +1,20 @@
import React from "react"
import { createPortal } from "react-dom"
import { useParams } from "react-router-dom"
import { CustomMainMenu } from "./CustomMainMenu"
import { CustomToolbar } from "./CustomToolbar"
import { CustomContextMenu } from "./CustomContextMenu"
import { FocusLockIndicator } from "./FocusLockIndicator"
import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar"
import { CommandPalette } from "./CommandPalette"
import { UserSettingsModal } from "./UserSettingsModal"
import { NetworkGraphPanel } from "../components/networking"
import CryptIDDropdown from "../components/auth/CryptIDDropdown"
import StarBoardButton from "../components/StarBoardButton"
import ShareBoardButton from "../components/ShareBoardButton"
import { SettingsDialog } from "./SettingsDialog"
import { useAuth } from "../context/AuthContext"
import { PermissionLevel } from "../lib/auth/types"
import { WORKER_URL } from "../constants/workerUrl"
import {
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
@ -32,16 +36,103 @@ const AI_TOOLS = [
{ id: 'mycelial', name: 'Mycelial', icon: '🍄', model: 'llama3.1:70b', provider: 'Ollama', type: 'local' },
];
// Permission labels and colors
const PERMISSION_CONFIG: Record<PermissionLevel, { label: string; color: string; icon: string }> = {
view: { label: 'View Only', color: '#6b7280', icon: '👁️' },
edit: { label: 'Edit', color: '#3b82f6', icon: '✏️' },
admin: { label: 'Admin', color: '#10b981', icon: '👑' },
}
// Custom SharePanel with layout: CryptID -> Star -> Gear -> Question mark
function CustomSharePanel() {
const tools = useTools()
const actions = useActions()
const { addDialog, removeDialog } = useDialogs()
const { session } = useAuth()
const { slug } = useParams<{ slug: string }>()
const boardId = slug || 'mycofi33'
const [showShortcuts, setShowShortcuts] = React.useState(false)
const [showSettings, setShowSettings] = React.useState(false)
const [showSettingsDropdown, setShowSettingsDropdown] = React.useState(false)
const [showAISection, setShowAISection] = React.useState(false)
const [hasApiKey, setHasApiKey] = React.useState(false)
const [permissionRequestStatus, setPermissionRequestStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
const [requestMessage, setRequestMessage] = React.useState('')
// Refs for dropdown positioning
const settingsButtonRef = React.useRef<HTMLButtonElement>(null)
const shortcutsButtonRef = React.useRef<HTMLButtonElement>(null)
const [settingsDropdownPos, setSettingsDropdownPos] = React.useState<{ top: number; right: number } | null>(null)
const [shortcutsDropdownPos, setShortcutsDropdownPos] = React.useState<{ top: number; right: number } | null>(null)
// Get current permission from session
// Authenticated users default to 'edit', unauthenticated to 'view'
const currentPermission: PermissionLevel = session.currentBoardPermission || (session.authed ? 'edit' : 'view')
// Request permission upgrade
const handleRequestPermission = async (requestedLevel: PermissionLevel) => {
if (!session.authed || !session.username) {
setRequestMessage('Please sign in to request permissions')
return
}
setPermissionRequestStatus('sending')
try {
const response = await fetch(`${WORKER_URL}/boards/${boardId}/permission-request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: session.username,
email: session.email,
requestedPermission: requestedLevel,
currentPermission,
boardId,
}),
})
if (response.ok) {
setPermissionRequestStatus('sent')
setRequestMessage(`Request for ${PERMISSION_CONFIG[requestedLevel].label} access sent to board admins`)
setTimeout(() => {
setPermissionRequestStatus('idle')
setRequestMessage('')
}, 5000)
} else {
throw new Error('Failed to send request')
}
} catch (error) {
console.error('Permission request error:', error)
setPermissionRequestStatus('error')
setRequestMessage('Failed to send request. Please try again.')
setTimeout(() => {
setPermissionRequestStatus('idle')
setRequestMessage('')
}, 3000)
}
}
// Update dropdown positions when they open
React.useEffect(() => {
if (showSettingsDropdown && settingsButtonRef.current) {
const rect = settingsButtonRef.current.getBoundingClientRect()
setSettingsDropdownPos({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
})
}
}, [showSettingsDropdown])
React.useEffect(() => {
if (showShortcuts && shortcutsButtonRef.current) {
const rect = shortcutsButtonRef.current.getBoundingClientRect()
setShortcutsDropdownPos({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
})
}
}, [showShortcuts])
// ESC key handler for closing dropdowns
React.useEffect(() => {
@ -236,8 +327,9 @@ function CustomSharePanel() {
<Separator />
{/* Settings gear button with dropdown */}
<div style={{ position: 'relative', padding: '0 2px' }}>
<div style={{ padding: '0 2px' }}>
<button
ref={settingsButtonRef}
onClick={() => setShowSettingsDropdown(!showSettingsDropdown)}
className="share-panel-btn"
style={{
@ -272,8 +364,8 @@ function CustomSharePanel() {
</svg>
</button>
{/* Settings dropdown */}
{showSettingsDropdown && (
{/* Settings dropdown - rendered via portal to break out of parent container */}
{showSettingsDropdown && settingsDropdownPos && createPortal(
<>
{/* Backdrop - only uses onClick, not onPointerDown */}
<div
@ -288,9 +380,9 @@ function CustomSharePanel() {
{/* Dropdown menu */}
<div
style={{
position: 'absolute',
top: 'calc(100% + 8px)',
right: 0,
position: 'fixed',
top: settingsDropdownPos.top,
right: settingsDropdownPos.right,
minWidth: '200px',
maxHeight: '60vh',
overflowY: 'auto',
@ -306,6 +398,121 @@ function CustomSharePanel() {
onWheel={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{/* Board Permission Display */}
<div style={{ padding: '10px 16px' }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: '10px', fontSize: '13px', color: 'var(--color-text)' }}>
<span style={{ fontSize: '16px' }}>🔐</span>
<span>Board Permission</span>
</span>
<span style={{
fontSize: '11px',
padding: '2px 8px',
borderRadius: '4px',
background: `${PERMISSION_CONFIG[currentPermission].color}20`,
color: PERMISSION_CONFIG[currentPermission].color,
fontWeight: 600,
}}>
{PERMISSION_CONFIG[currentPermission].icon} {PERMISSION_CONFIG[currentPermission].label}
</span>
</div>
{/* Permission levels with request buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '8px' }}>
{(['view', 'edit', 'admin'] as PermissionLevel[]).map((level) => {
const config = PERMISSION_CONFIG[level]
const isCurrent = currentPermission === level
const canRequest = session.authed && !isCurrent && (
(level === 'edit' && currentPermission === 'view') ||
(level === 'admin' && currentPermission !== 'admin')
)
return (
<div
key={level}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 8px',
borderRadius: '6px',
background: isCurrent ? `${config.color}15` : 'transparent',
border: isCurrent ? `1px solid ${config.color}40` : '1px solid transparent',
}}
>
<span style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '12px',
color: isCurrent ? config.color : 'var(--color-text-3)',
fontWeight: isCurrent ? 600 : 400,
}}>
<span>{config.icon}</span>
<span>{config.label}</span>
{isCurrent && <span style={{ fontSize: '10px', opacity: 0.7 }}>(current)</span>}
</span>
{canRequest && (
<button
onClick={() => handleRequestPermission(level)}
disabled={permissionRequestStatus === 'sending'}
style={{
padding: '3px 8px',
fontSize: '10px',
fontWeight: 500,
borderRadius: '4px',
border: 'none',
background: config.color,
color: 'white',
cursor: permissionRequestStatus === 'sending' ? 'wait' : 'pointer',
opacity: permissionRequestStatus === 'sending' ? 0.6 : 1,
}}
>
{permissionRequestStatus === 'sending' ? '...' : 'Request'}
</button>
)}
</div>
)
})}
</div>
{/* Request status message */}
{requestMessage && (
<p style={{
margin: '8px 0 0',
fontSize: '11px',
padding: '6px 8px',
borderRadius: '4px',
background: permissionRequestStatus === 'sent' ? '#d1fae5' :
permissionRequestStatus === 'error' ? '#fee2e2' : 'var(--color-muted-2)',
color: permissionRequestStatus === 'sent' ? '#065f46' :
permissionRequestStatus === 'error' ? '#dc2626' : 'var(--color-text-3)',
textAlign: 'center',
}}>
{requestMessage}
</p>
)}
{!session.authed && (
<p style={{
margin: '8px 0 0',
fontSize: '10px',
color: 'var(--color-text-3)',
textAlign: 'center',
}}>
Sign in to request higher permissions
</p>
)}
</div>
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '4px 0' }} />
{/* Dark mode toggle */}
<button
onClick={() => {
@ -444,42 +651,9 @@ function CustomSharePanel() {
)}
</div>
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '4px 0' }} />
{/* All settings */}
<button
onClick={() => {
setShowSettingsDropdown(false)
setShowSettings(true)
}}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 16px',
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--color-text)',
fontSize: '13px',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-muted-2)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'none'
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span>All Settings...</span>
</button>
</div>
</>
</>,
document.body
)}
</div>
@ -488,6 +662,7 @@ function CustomSharePanel() {
{/* Help/Keyboard shortcuts button - rightmost */}
<div style={{ padding: '0 4px' }}>
<button
ref={shortcutsButtonRef}
onClick={() => setShowShortcuts(!showShortcuts)}
className="share-panel-btn"
style={{
@ -525,8 +700,8 @@ function CustomSharePanel() {
</div>
</div>
{/* Keyboard shortcuts panel */}
{showShortcuts && (
{/* Keyboard shortcuts panel - rendered via portal to break out of parent container */}
{showShortcuts && shortcutsDropdownPos && createPortal(
<>
{/* Backdrop - only uses onClick, not onPointerDown */}
<div
@ -541,11 +716,11 @@ function CustomSharePanel() {
{/* Shortcuts menu */}
<div
style={{
position: 'absolute',
top: 'calc(100% + 8px)',
right: 0,
position: 'fixed',
top: shortcutsDropdownPos.top,
right: shortcutsDropdownPos.right,
width: '320px',
maxHeight: '60vh',
maxHeight: '50vh',
overflowY: 'auto',
overflowX: 'hidden',
background: 'var(--color-panel)',
@ -553,7 +728,7 @@ function CustomSharePanel() {
borderRadius: '8px',
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
zIndex: 99999,
padding: '12px 0',
padding: '10px 0',
pointerEvents: 'auto',
}}
onWheel={(e) => e.stopPropagation()}
@ -629,17 +804,10 @@ function CustomSharePanel() {
</div>
))}
</div>
</>
</>,
document.body
)}
{/* Settings Modal */}
{showSettings && (
<UserSettingsModal
onClose={() => setShowSettings(false)}
isDarkMode={isDarkMode}
onToggleDarkMode={handleToggleDarkMode}
/>
)}
</div>
)
}

View File

@ -154,6 +154,7 @@ export async function handleGetPermission(
const db = env.CRYPTID_DB;
if (!db) {
// No database - default to view for anonymous (secure by default)
console.log('🔐 Permission check: No database configured');
return new Response(JSON.stringify({
permission: 'view',
isOwner: false,
@ -168,6 +169,10 @@ export async function handleGetPermission(
let userId: string | null = null;
const publicKey = request.headers.get('X-CryptID-PublicKey');
console.log('🔐 Permission check for board:', boardId, {
publicKeyReceived: publicKey ? `${publicKey.substring(0, 20)}...` : null
});
if (publicKey) {
const deviceKey = await db.prepare(
'SELECT user_id FROM device_keys WHERE public_key = ?'
@ -175,6 +180,9 @@ export async function handleGetPermission(
if (deviceKey) {
userId = deviceKey.user_id;
console.log('🔐 Found user ID for public key:', userId);
} else {
console.log('🔐 No user found for public key');
}
}
@ -183,6 +191,7 @@ export async function handleGetPermission(
const accessToken = url.searchParams.get('token');
const result = await getEffectivePermission(db, boardId, userId, accessToken);
console.log('🔐 Permission result:', result);
return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' },
@ -293,6 +302,10 @@ export async function handleListPermissions(
* POST /boards/:boardId/permissions
* Grant permission to a user (admin only)
* Body: { userId, permission, username? }
*
* Note: This endpoint allows granting 'admin' permission because it requires
* specifying a user by ID or CryptID username. This is the ONLY way to grant
* admin access - share links (access tokens) can only grant 'view' or 'edit'.
*/
export async function handleGrantPermission(
boardId: string,
@ -709,8 +722,12 @@ export async function handleCreateAccessToken(
maxUses?: number;
};
if (!body.permission || !['view', 'edit', 'admin'].includes(body.permission)) {
return new Response(JSON.stringify({ error: 'Invalid permission level' }), {
// Only allow 'view' and 'edit' permissions for access tokens
// Admin permission must be granted directly by username/email through handleGrantPermission
if (!body.permission || !['view', 'edit'].includes(body.permission)) {
return new Response(JSON.stringify({
error: 'Invalid permission level. Share links can only grant view or edit access. Use direct permission grants for admin access.'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});

View File

@ -847,3 +847,231 @@ export async function handleRevokeDevice(
});
}
}
/**
* Check if a username is available
* GET /auth/check-username?username=<username>
*
* Returns whether a username is available for registration.
* Used during the CryptID registration flow.
*/
export async function handleCheckUsername(
request: Request,
env: Environment
): Promise<Response> {
try {
const url = new URL(request.url);
const username = url.searchParams.get('username');
if (!username) {
return new Response(JSON.stringify({ error: 'Username is required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// Normalize username (lowercase, no special chars)
const normalizedUsername = username.toLowerCase().replace(/[^a-z0-9_-]/g, '');
if (normalizedUsername.length < 3) {
return new Response(JSON.stringify({
available: false,
error: 'Username must be at least 3 characters'
}), {
headers: { 'Content-Type': 'application/json' },
});
}
if (normalizedUsername.length > 20) {
return new Response(JSON.stringify({
available: false,
error: 'Username must be 20 characters or less'
}), {
headers: { 'Content-Type': 'application/json' },
});
}
const db = env.CRYPTID_DB;
if (!db) {
// If no database, assume username is available (graceful degradation)
return new Response(JSON.stringify({ available: true }), {
headers: { 'Content-Type': 'application/json' },
});
}
// Check if username exists in database
const existingUser = await db.prepare(
'SELECT id FROM users WHERE cryptid_username = ?'
).bind(normalizedUsername).first<{ id: string }>();
return new Response(JSON.stringify({
available: !existingUser,
username: normalizedUsername,
error: existingUser ? 'Username is already taken' : null
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Check username error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
/**
* Search for CryptID users by username
* GET /auth/users/search?q=<query>
*
* This endpoint allows searching for users by CryptID username.
* Used for granting permissions to users by username.
*/
export async function handleSearchUsers(
request: Request,
env: Environment
): Promise<Response> {
try {
const url = new URL(request.url);
const query = url.searchParams.get('q');
if (!query || query.length < 2) {
return new Response(JSON.stringify({ error: 'Query must be at least 2 characters' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const db = env.CRYPTID_DB;
if (!db) {
return new Response(JSON.stringify({ error: 'Database not configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
// Search users by username (case-insensitive)
const users = await db.prepare(`
SELECT id, cryptid_username, email, email_verified, created_at
FROM users
WHERE cryptid_username LIKE ?
ORDER BY cryptid_username
LIMIT 20
`).bind(`%${query}%`).all<{
id: string;
cryptid_username: string;
email: string;
email_verified: number;
created_at: string;
}>();
return new Response(JSON.stringify({
users: (users.results || []).map(u => ({
id: u.id,
username: u.cryptid_username,
emailVerified: u.email_verified === 1,
createdAt: u.created_at
}))
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Search users error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
/**
* List all CryptID users (admin endpoint)
* GET /admin/users
* Query params: ?limit=<number>&offset=<number>
*
* This endpoint requires admin authentication.
* For now, we'll require a simple admin secret header.
*/
export async function handleListAllUsers(
request: Request,
env: Environment
): Promise<Response> {
try {
// Check for admin secret (simple auth for now)
const adminSecret = request.headers.get('X-Admin-Secret');
if (!adminSecret || adminSecret !== env.ADMIN_SECRET) {
return new Response(JSON.stringify({ error: 'Unauthorized - admin access required' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const db = env.CRYPTID_DB;
if (!db) {
return new Response(JSON.stringify({ error: 'Database not configured' }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
});
}
const url = new URL(request.url);
const limit = Math.min(parseInt(url.searchParams.get('limit') || '100'), 1000);
const offset = parseInt(url.searchParams.get('offset') || '0');
// Get total count
const countResult = await db.prepare('SELECT COUNT(*) as count FROM users').first<{ count: number }>();
const totalCount = countResult?.count || 0;
// Get users with device count
const users = await db.prepare(`
SELECT
u.id,
u.cryptid_username,
u.email,
u.email_verified,
u.created_at,
u.updated_at,
(SELECT COUNT(*) FROM device_keys dk WHERE dk.user_id = u.id) as device_count
FROM users u
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?
`).bind(limit, offset).all<{
id: string;
cryptid_username: string;
email: string;
email_verified: number;
created_at: string;
updated_at: string;
device_count: number;
}>();
return new Response(JSON.stringify({
users: (users.results || []).map(u => ({
id: u.id,
username: u.cryptid_username,
email: u.email,
emailVerified: u.email_verified === 1,
deviceCount: u.device_count,
createdAt: u.created_at,
updatedAt: u.updated_at
})),
pagination: {
total: totalCount,
limit,
offset,
hasMore: offset + limit < totalCount
}
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('List all users error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}

View File

@ -6,7 +6,7 @@ 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,
cryptid_username TEXT UNIQUE NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
@ -89,12 +89,13 @@ CREATE INDEX IF NOT EXISTS idx_board_perms_user ON board_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_board_perms_board_user ON board_permissions(board_id, user_id);
-- Access tokens for shareable links with specific permission levels
-- Anonymous users can use these tokens to get edit/admin access without authentication
-- Anonymous users can use these tokens to get view/edit access without authentication
-- Note: Admin permission cannot be shared via token - must be granted directly by username/email
CREATE TABLE IF NOT EXISTS board_access_tokens (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE, -- Random hex token (64 chars)
permission TEXT NOT NULL CHECK (permission IN ('view', 'edit', 'admin')),
permission TEXT NOT NULL CHECK (permission IN ('view', 'edit')),
created_by TEXT NOT NULL, -- User ID who created the token
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT, -- NULL = never expires

View File

@ -13,6 +13,8 @@ export interface Environment {
RESEND_API_KEY?: string;
CRYPTID_EMAIL_FROM?: string;
APP_URL?: string;
// Admin secret for protected endpoints
ADMIN_SECRET?: string;
}
// CryptID types for auth

View File

@ -29,6 +29,9 @@ import {
} from "./boardPermissions"
import {
handleSendBackupEmail,
handleSearchUsers,
handleListAllUsers,
handleCheckUsername,
} from "./cryptidAuth"
// make sure our sync durable objects are made available to cloudflare
@ -128,6 +131,7 @@ const { preflight, corsify } = cors({
"X-CryptID-PublicKey", // CryptID authentication header
"X-User-Id", // User ID header for networking API
"X-Api-Key", // API key header for external services
"X-Admin-Secret", // Admin secret header for protected endpoints
"*"
],
maxAge: 86400,
@ -832,13 +836,71 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
}
})
// =============================================================================
// Miro Import API
// =============================================================================
// Proxy endpoint for fetching external images (needed for CORS-blocked Miro images)
.get("/proxy", async (req) => {
const url = new URL(req.url)
const targetUrl = url.searchParams.get('url')
if (!targetUrl) {
return new Response(JSON.stringify({ error: 'Missing url parameter' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const response = await fetch(targetUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; TldrawImporter/1.0)',
}
})
if (!response.ok) {
return new Response(JSON.stringify({ error: `Failed to fetch: ${response.statusText}` }), {
status: response.status,
headers: { 'Content-Type': 'application/json' }
})
}
// Get content type and pass through the image
const contentType = response.headers.get('content-type') || 'application/octet-stream'
const body = await response.arrayBuffer()
return new Response(body, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
}
})
} catch (error) {
console.error('Proxy fetch error:', error)
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
})
// =============================================================================
// CryptID Auth API
// =============================================================================
// Check if a username is available for registration
.get("/api/auth/check-username", handleCheckUsername)
// Send backup email for multi-device setup
.post("/api/auth/send-backup-email", handleSendBackupEmail)
// Search for CryptID users by username (for granting permissions)
.get("/api/auth/users/search", handleSearchUsers)
// List all CryptID users (admin only, requires X-Admin-Secret header)
.get("/admin/users", handleListAllUsers)
// =============================================================================
// User Networking / Social Graph API
// =============================================================================