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 f277aeec12
commit 4236f040f3
34 changed files with 5616 additions and 1665 deletions

View File

@ -274,12 +274,7 @@ export function useAutomergeStoreV2({
return return
} }
// Broadcasting changes via JSON sync // Broadcasting changes via JSON sync (logging disabled for performance)
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`)
}
if (adapter && typeof (adapter as any).send === 'function') { if (adapter && typeof (adapter as any).send === 'function') {
// Send changes to other clients via the network adapter // 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 // Listen for changes from Automerge and apply them to TLDraw
const automergeChangeHandler = (payload: DocHandleChangePayload<any>) => { const automergeChangeHandler = (payload: DocHandleChangePayload<any>) => {
const patchCount = payload.patches?.length || 0 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. // Skip echoes of our own local changes using a counter.
// Each local handle.change() increments the counter, and each echo decrements it. // 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). // Only process changes when counter is 0 (those are remote changes from other clients).
if (pendingLocalChanges > 0) { if (pendingLocalChanges > 0) {
console.log(`⏭️ Skipping echo (pendingLocalChanges was ${pendingLocalChanges}, now ${pendingLocalChanges - 1})`)
pendingLocalChanges-- pendingLocalChanges--
return return
} }
console.log(`✅ Processing ${patchCount} patches as REMOTE changes (${shapePatches.length} shape patches)`)
try { try {
// Apply patches from Automerge to TLDraw store // Apply patches from Automerge to TLDraw store
if (payload.patches && payload.patches.length > 0) { 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 { 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 // 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 // This prevents coordinates from defaulting to 0,0 when patches create new records
const automergeDoc = handle.doc() const automergeDoc = handle.doc()
applyAutomergePatchesToTLStore(payload.patches, store, automergeDoc) 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) { } catch (patchError) {
console.error("Error applying patches batch, attempting individual patch application:", patchError) console.error("Error applying patches batch, attempting individual patch application:", patchError)
// Try applying patches one by one to identify problematic ones // 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 // Track recent eraser activity to detect active eraser drags
let lastEraserActivity = 0 let lastEraserActivity = 0
let eraserToolSelected = false 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_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 eraserChangeQueue: RecordsDiff<TLRecord> | null = null
let eraserCheckInterval: NodeJS.Timeout | null = null let eraserCheckInterval: NodeJS.Timeout | null = null
// Helper to check if eraser tool is actively erasing (to prevent saves during eraser drag) // 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 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 { 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) // Check instance_page_state for erasingShapeIds (most reliable indicator)
const instancePageState = allRecords.find((r: any) => if (instancePageState &&
r.typeName === 'instance_page_state' && (instancePageState as any).erasingShapeIds &&
(r as any).erasingShapeIds && Array.isArray((instancePageState as any).erasingShapeIds) &&
Array.isArray((r as any).erasingShapeIds) && (instancePageState as any).erasingShapeIds.length > 0) {
(r as any).erasingShapeIds.length > 0 lastEraserActivity = now
)
if (instancePageState) {
lastEraserActivity = Date.now()
eraserToolSelected = true eraserToolSelected = true
cachedEraserActive = true
return true // Eraser is actively erasing shapes return true // Eraser is actively erasing shapes
} }
// Check if eraser tool is selected // 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 const currentToolId = instance ? (instance as any).currentToolId : null
if (currentToolId === 'eraser') { if (currentToolId === 'eraser') {
eraserToolSelected = true eraserToolSelected = true
const now = Date.now() lastEraserActivity = now
// If eraser tool is selected, keep it active for longer to handle drags cachedEraserActive = true
// 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)
return true return true
} else { } else {
// Tool switched away - only consider active if very recent activity
eraserToolSelected = false eraserToolSelected = false
const now = Date.now()
if (now - lastEraserActivity < 300) {
return true // Very recent activity, might still be processing
}
} }
cachedEraserActive = false
return false return false
} catch (e) { } catch (e) {
// If we can't check, use last known state with timeout // If we can't check, use last known state with timeout
const now = Date.now()
if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) { if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
cachedEraserActive = true
return true return true
} }
cachedEraserActive = false
return false return false
} }
} }
// Track eraser activity from shape deletions // 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>) => { const checkForEraserActivity = (changes: RecordsDiff<TLRecord>) => {
// If shapes are being removed and eraser tool might be active, mark activity // If shapes are being removed and eraser tool might be active, mark activity
if (changes.removed) { if (changes.removed) {
const removedShapes = Object.values(changes.removed).filter((r: any) => const removedKeys = Object.keys(changes.removed)
r && r.typeName === 'shape' // Quick check: if no shape keys, skip
) const hasRemovedShapes = removedKeys.some(key => key.startsWith('shape:'))
if (removedShapes.length > 0) { if (hasRemovedShapes) {
// Check if eraser tool is currently selected // Use cached eraserToolSelected state if recent, avoid expensive allRecords() call
const allRecords = store.allRecords() const now = Date.now()
const instance = allRecords.find((r: any) => r.typeName === 'instance') if (eraserToolSelected || now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
if (instance && (instance as any).currentToolId === 'eraser') { lastEraserActivity = now
lastEraserActivity = Date.now()
eraserToolSelected = true
} }
} }
} }
@ -688,17 +669,6 @@ export function useAutomergeStoreV2({
id.startsWith('pointer:') 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 // Filter out if typeName matches OR if ID pattern matches ephemeral types
if (typeName && ephemeralTypes.includes(typeName)) { if (typeName && ephemeralTypes.includes(typeName)) {
// Skip - this is an ephemeral record // Skip - this is an ephemeral record
@ -721,183 +691,9 @@ export function useAutomergeStoreV2({
removed: filterEphemeral(changes.removed), removed: filterEphemeral(changes.removed),
} }
// DEBUG: Log all changes to see what's being detected // Calculate change counts (minimal, needed for early return)
const totalChanges = Object.keys(changes.added || {}).length + Object.keys(changes.updated || {}).length + Object.keys(changes.removed || {}).length
const filteredTotalChanges = Object.keys(filteredChanges.added || {}).length + Object.keys(filteredChanges.updated || {}).length + Object.keys(filteredChanges.removed || {}).length 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 // Skip if no meaningful changes after filtering ephemeral records
if (filteredTotalChanges === 0) { if (filteredTotalChanges === 0) {
return return
@ -906,7 +702,6 @@ export function useAutomergeStoreV2({
// CRITICAL: Skip broadcasting changes that came from remote sources to prevent feedback loops // CRITICAL: Skip broadcasting changes that came from remote sources to prevent feedback loops
// Only broadcast changes that originated from user interactions (source === 'user') // Only broadcast changes that originated from user interactions (source === 'user')
if (source === 'remote') { if (source === 'remote') {
console.log('🔄 Skipping broadcast for remote change to prevent feedback loop')
return return
} }
@ -1044,38 +839,6 @@ export function useAutomergeStoreV2({
// Check if this is a position-only update that should be throttled // Check if this is a position-only update that should be throttled
const isPositionOnly = isPositionOnlyUpdate(finalFilteredChanges) 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) { if (isPositionOnly && positionUpdateQueue === null) {
// Start a new queue for position updates // Start a new queue for position updates
positionUpdateQueue = finalFilteredChanges positionUpdateQueue = finalFilteredChanges
@ -1258,12 +1021,7 @@ export function useAutomergeStoreV2({
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds) broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
} }
// Only log if there are many changes or if debugging is needed // Logging disabled for performance during continuous drawing
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`)
}
// Check if the document actually changed // Check if the document actually changed
const docAfter = handle.doc() 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`) 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 update callback - applies presence from other clients
// Presence is ephemeral (cursors, selections) and goes directly to the store // 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 // 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() lastActivityTimestamp: Date.now()
}) })
// Apply the instance_presence record using mergeRemoteChanges for atomic updates // Queue the presence update for batched application
currentStore.mergeRemoteChanges(() => { pendingPresenceUpdates.current.set(presenceId, instancePresence)
currentStore.put([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) { } catch (error) {
console.error('❌ Error applying presence:', error) console.error('❌ Error applying presence:', error)
} }
}, []) }, [flushPresenceUpdates])
const { repo, adapter, storageAdapter } = useMemo(() => { const { repo, adapter, storageAdapter } = useMemo(() => {
const adapter = new CloudflareNetworkAdapter( const adapter = new CloudflareNetworkAdapter(
@ -541,6 +571,11 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
return () => { return () => {
mounted = false 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 // Disconnect adapter on unmount to clean up WebSocket connection
if (adapter) { if (adapter) {
adapter.disconnect?.() 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 { useParams } from 'react-router-dom';
import { useDialogs } from 'tldraw'; import { QRCodeSVG } from 'qrcode.react';
import { InviteDialog } from '../ui/InviteDialog';
interface ShareBoardButtonProps { interface ShareBoardButtonProps {
className?: string; 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 ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) => {
const { slug } = useParams<{ slug: string }>(); 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 boardSlug = slug || 'mycofi33'; const boardUrl = `${window.location.origin}/board/${boardSlug}`;
const boardUrl = `${window.location.origin}/board/${boardSlug}`;
addDialog({ // Update dropdown position when it opens
id: "invite-dialog", useEffect(() => {
component: ({ onClose }: { onClose: () => void }) => ( if (showDropdown && triggerRef.current) {
<InviteDialog const rect = triggerRef.current.getBoundingClientRect();
onClose={() => { setDropdownPosition({
onClose(); top: rect.bottom + 8,
removeDialog("invite-dialog"); right: window.innerWidth - rect.right,
}} });
boardUrl={boardUrl} }
boardSlug={boardSlug} }, [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) // Detect if we're in share-panel (compact) vs toolbar (full button)
const isCompact = className.includes('share-panel-btn'); const isCompact = className.includes('share-panel-btn');
if (isCompact) { if (isCompact) {
// Icon-only version for the top-right share panel // Icon-only version for the top-right share panel with dropdown
return ( return (
<button <div ref={dropdownRef} style={{ pointerEvents: 'all' }}>
onClick={handleShare} <button
className={`share-board-button ${className}`} ref={triggerRef}
title="Invite others to this board" onClick={() => setShowDropdown(!showDropdown)}
style={{ className={`share-board-button ${className}`}
background: 'none', title="Invite others to this board"
border: 'none', style={{
padding: '6px', background: showDropdown ? 'var(--color-muted-2)' : 'none',
cursor: 'pointer', border: 'none',
borderRadius: '6px', padding: '6px',
display: 'flex', cursor: 'pointer',
alignItems: 'center', borderRadius: '6px',
justifyContent: 'center', display: 'flex',
color: 'var(--color-text-1)', alignItems: 'center',
opacity: 0.7, justifyContent: 'center',
transition: 'opacity 0.15s, background 0.15s', color: 'var(--color-text-1)',
pointerEvents: 'all', opacity: showDropdown ? 1 : 0.7,
}} transition: 'opacity 0.15s, background 0.15s',
onMouseEnter={(e) => { pointerEvents: 'all',
e.currentTarget.style.opacity = '1'; }}
e.currentTarget.style.background = 'var(--color-muted-2)'; onMouseEnter={(e) => {
}} e.currentTarget.style.opacity = '1';
onMouseLeave={(e) => { e.currentTarget.style.background = 'var(--color-muted-2)';
e.currentTarget.style.opacity = '0.7'; }}
e.currentTarget.style.background = 'none'; onMouseLeave={(e) => {
}} if (!showDropdown) {
> e.currentTarget.style.opacity = '0.7';
{/* User with plus icon (invite/add person) */} e.currentTarget.style.background = 'none';
<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" /> {/* User with plus icon (invite/add person) */}
{/* Plus sign */} <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="19" y1="8" x2="19" y2="14" /> {/* User outline */}
<line x1="16" y1="11" x2="22" y2="11" /> <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
</svg> <circle cx="9" cy="7" r="4" />
</button> {/* 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.) // Full button version for other contexts (toolbar, etc.)
return ( return (
<button <div ref={dropdownRef} style={{ position: 'relative' }}>
onClick={handleShare} <button
className={`share-board-button ${className}`} onClick={() => setShowDropdown(!showDropdown)}
title="Invite others to this board" className={`share-board-button ${className}`}
style={{ title="Invite others to this board"
padding: "4px 8px", style={{
borderRadius: "4px", padding: "4px 8px",
background: "#3b82f6", borderRadius: "4px",
color: "white", background: "#3b82f6",
border: "none", color: "white",
cursor: "pointer", border: "none",
fontWeight: 500, cursor: "pointer",
transition: "background 0.2s ease", fontWeight: 500,
boxShadow: "0 2px 4px rgba(0,0,0,0.1)", transition: "background 0.2s ease",
whiteSpace: "nowrap", boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
userSelect: "none", whiteSpace: "nowrap",
display: "flex", userSelect: "none",
alignItems: "center", display: "flex",
gap: "4px", alignItems: "center",
height: "22px", gap: "4px",
minHeight: "22px", height: "22px",
boxSizing: "border-box", minHeight: "22px",
fontSize: "0.75rem", boxSizing: "border-box",
}} fontSize: "0.75rem",
onMouseEnter={(e) => { }}
e.currentTarget.style.background = "#2563eb"; onMouseEnter={(e) => {
}} e.currentTarget.style.background = "#2563eb";
onMouseLeave={(e) => { }}
e.currentTarget.style.background = "#3b82f6"; 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"> {/* User with plus icon (invite/add person) */}
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="9" cy="7" r="4" /> <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<line x1="19" y1="8" x2="19" y2="14" /> <circle cx="9" cy="7" r="4" />
<line x1="16" y1="11" x2="22" y2="11" /> <line x1="19" y1="8" x2="19" y2="14" />
</svg> <line x1="16" y1="11" x2="22" y2="11" />
</button> </svg>
</button>
</div>
); );
}; };

View File

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

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState } from 'react';
import { useAuth } from '../../context/AuthContext'; import { createPortal } from 'react-dom';
import CryptID from './CryptID'; import CryptID from './CryptID';
import '../../css/anonymous-banner.css'; import '../../css/anonymous-banner.css';
@ -13,28 +13,27 @@ interface AnonymousViewerBannerProps {
/** /**
* Banner shown to anonymous (unauthenticated) users viewing a board. * Banner shown to anonymous (unauthenticated) users viewing a board.
* Explains CryptID and provides a smooth sign-up flow. * 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> = ({ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
onAuthenticated, onAuthenticated,
triggeredByEdit = false triggeredByEdit = false
}) => { }) => {
const { session } = useAuth();
const [isDismissed, setIsDismissed] = useState(false); const [isDismissed, setIsDismissed] = useState(false);
const [showSignUp, setShowSignUp] = useState(false); const [showSignUp, setShowSignUp] = useState(false);
const [isExpanded, setIsExpanded] = useState(triggeredByEdit);
// Check if banner was previously dismissed this session // Note: We intentionally do NOT persist banner dismissal across page loads.
useEffect(() => { // The banner should appear on each new page load for anonymous users
const dismissed = sessionStorage.getItem('anonymousBannerDismissed'); // to remind them about CryptID. Only dismiss within the current component lifecycle.
if (dismissed && !triggeredByEdit) { //
setIsDismissed(true); // Previous implementation used sessionStorage to remember dismissal, but this caused
} // issues where users who dismissed once would never see it again until they closed
}, [triggeredByEdit]); // their browser entirely - even if they logged out or their session expired.
//
// If user is authenticated, don't show banner // If triggeredByEdit is true, always show regardless of dismiss state.
if (session.authed) {
return null;
}
// If dismissed and not triggered by edit, don't show // If dismissed and not triggered by edit, don't show
if (isDismissed && !triggeredByEdit) { if (isDismissed && !triggeredByEdit) {
@ -42,7 +41,8 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
} }
const handleDismiss = () => { 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); setIsDismissed(true);
}; };
@ -52,6 +52,9 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
const handleSignUpSuccess = () => { const handleSignUpSuccess = () => {
setShowSignUp(false); 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) { if (onAuthenticated) {
onAuthenticated(); onAuthenticated();
} }
@ -61,107 +64,134 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
setShowSignUp(false); 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 ( return (
<div className={`anonymous-viewer-banner ${triggeredByEdit ? 'edit-triggered' : ''} ${isExpanded ? 'expanded' : ''}`}> <div className={`anonymous-viewer-banner ${triggeredByEdit ? 'edit-triggered' : ''}`}>
<div className="banner-content"> {/* Dismiss button in top-right corner */}
<div className="banner-icon"> {!triggeredByEdit && (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <button
<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"/> 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> </svg>
</div> </button>
)}
<div className="banner-text"> <div className="banner-content">
{triggeredByEdit ? ( <div className="banner-header">
<p className="banner-headline"> <div className="banner-icon">
<strong>Want to edit this board?</strong> <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
</p> <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>
<p className="banner-headline"> </div>
<strong>You're viewing this board anonymously</strong>
</p>
)}
{isExpanded ? ( <div className="banner-text">
<div className="banner-details"> {triggeredByEdit ? (
<p> <p className="banner-headline">
Sign in by creating a username as your <strong>CryptID</strong> &mdash; no password required! <strong>Sign in to edit</strong>
</p> </p>
<ul className="cryptid-benefits"> ) : (
<li> <p className="banner-headline">
<span className="benefit-icon">&#x1F512;</span> <strong>Viewing anonymously</strong>
<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> </p>
</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-summary"> <p className="banner-summary">
Create a free CryptID to edit this board &mdash; no password needed! Sign in with CryptID to edit
</p> </p>
)} </div>
</div> </div>
{/* Action button */}
<div className="banner-actions"> <div className="banner-actions">
<button <button
className="banner-signup-btn" className="banner-signup-btn"
onClick={handleSignUpClick} onClick={handleSignUpClick}
> >
Create CryptID Sign in
</button> </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>
</div> </div>
{triggeredByEdit && ( {triggeredByEdit && (
<div className="banner-edit-notice"> <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"/> <path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" fill="currentColor"/>
</svg> </svg>
<span>This board is in read-only mode for anonymous viewers</span> <span>Read-only for anonymous viewers</span>
</div> </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> </div>
); );
}; };

View File

@ -4,6 +4,7 @@ import { useAuth } from '../../context/AuthContext';
import { useNotifications } from '../../context/NotificationContext'; import { useNotifications } from '../../context/NotificationContext';
import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser'; import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser';
import { WORKER_URL } from '../../constants/workerUrl'; import { WORKER_URL } from '../../constants/workerUrl';
import '../../css/crypto-auth.css'; // For spin animation
interface CryptIDProps { interface CryptIDProps {
onSuccess?: () => void; onSuccess?: () => void;
@ -26,6 +27,8 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
const [existingUsers, setExistingUsers] = useState<string[]>([]); const [existingUsers, setExistingUsers] = useState<string[]>([]);
const [suggestedUsername, setSuggestedUsername] = useState<string>(''); const [suggestedUsername, setSuggestedUsername] = useState<string>('');
const [emailSent, setEmailSent] = useState(false); const [emailSent, setEmailSent] = useState(false);
const [usernameAvailable, setUsernameAvailable] = useState<boolean | null>(null);
const [checkingUsername, setCheckingUsername] = useState(false);
const [browserSupport, setBrowserSupport] = useState<{ const [browserSupport, setBrowserSupport] = useState<{
supported: boolean; supported: boolean;
secure: boolean; secure: boolean;
@ -97,6 +100,45 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
checkExistingUsers(); checkExistingUsers();
}, [addNotification]); }, [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 * Send backup email with magic link
*/ */
@ -160,8 +202,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
/** /**
* Handle login * Handle login
*/ */
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async () => {
e.preventDefault();
setError(null); setError(null);
setIsLoading(true); setIsLoading(true);
@ -240,20 +281,34 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
<div style={styles.explainerItem}> <div style={styles.explainerItem}>
<span style={styles.explainerIcon}>🔑</span> <span style={styles.explainerIcon}>🔑</span>
<div> <div>
<strong>Cryptographic Keys</strong> <strong>No Password Needed</strong>
<p style={styles.explainerText}> <p style={styles.explainerText}>
When you create an account, your browser generates a unique cryptographic key pair. Encrypted keys are created directly on your device using the{' '}
The private key never leaves your device. <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> </p>
</div> </div>
</div> </div>
<div style={styles.explainerItem}> <div style={styles.explainerItem}>
<span style={styles.explainerIcon}>💾</span> <span style={styles.explainerIcon}>💾</span>
<div> <div>
<strong>Secure Storage</strong> <strong>Secure Browser Storage</strong>
<p style={styles.explainerText}> <p style={styles.explainerText}>
Your keys are stored securely in your browser using WebCryptoAPI - Your cryptographic keys encrypt your data locally using local-first architecture.
the same technology used by banks and governments. 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> </p>
</div> </div>
</div> </div>
@ -262,8 +317,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
<div> <div>
<strong>Multi-Device Access</strong> <strong>Multi-Device Access</strong>
<p style={styles.explainerText}> <p style={styles.explainerText}>
Add your email to receive a backup link. Open it on another device Add a mobile device or tablet and link keys for one streamlined identity across all your devices.
(like your phone) to sync your account securely.
</p> </p>
</div> </div>
</div> </div>
@ -272,14 +326,14 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
<div style={styles.featureList}> <div style={styles.featureList}>
<div style={styles.featureItem}> <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>
<div style={styles.featureItem}> <div style={styles.featureItem}>
<span style={{ color: '#22c55e' }}></span> Phishing-resistant authentication <span style={{ color: '#22c55e' }}></span> Phishing-resistant authentication
</div> </div>
<div style={styles.featureItem}>
<span style={{ color: '#22c55e' }}></span> Your data stays encrypted
</div>
</div> </div>
<button <button
@ -296,7 +350,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
setUsername(existingUsers[0]); setUsername(existingUsers[0]);
} }
}} }}
style={styles.linkButton} style={{ ...styles.linkButton, marginTop: '20px' }}
> >
Already have an account? Sign in Already have an account? Sign in
</button> </button>
@ -310,24 +364,82 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
<h2 style={styles.title}>Choose Your Username</h2> <h2 style={styles.title}>Choose Your Username</h2>
<p style={styles.subtitle}>This is your unique identity on the platform</p> <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}> <div style={styles.inputGroup}>
<label style={styles.label}>Username</label> <label style={styles.label}>Username</label>
<input <div style={{ position: 'relative' }}>
type="text" <input
value={username} type="text"
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))} value={username}
placeholder="e.g., alex_smith" onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
style={styles.input} placeholder="e.g., alex_smith"
required style={{
minLength={3} ...styles.input,
maxLength={20} paddingRight: '40px',
autoFocus borderColor: username.length >= 3
/> ? (usernameAvailable === true ? '#22c55e'
<p style={styles.hint}>3-20 characters, lowercase letters, numbers, _ and -</p> : 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> </div>
{error && <div style={styles.error}>{error}</div>} {error && !usernameAvailable && <div style={styles.error}>{error}</div>}
<div style={styles.buttonGroup}> <div style={styles.buttonGroup}>
<button <button
@ -339,14 +451,14 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
</button> </button>
<button <button
type="submit" type="submit"
disabled={username.length < 3} disabled={username.length < 3 || usernameAvailable === false || checkingUsername}
style={{ style={{
...styles.primaryButton, ...styles.primaryButton,
opacity: username.length < 3 ? 0.5 : 1, opacity: (username.length < 3 || usernameAvailable === false || checkingUsername) ? 0.5 : 1,
cursor: username.length < 3 ? 'not-allowed' : 'pointer', cursor: (username.length < 3 || usernameAvailable === false || checkingUsername) ? 'not-allowed' : 'pointer',
}} }}
> >
Continue {checkingUsername ? 'Checking...' : 'Continue'}
</button> </button>
</div> </div>
</form> </form>
@ -457,11 +569,6 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
</div> </div>
)} )}
{onCancel && registrationStep !== 'success' && (
<button onClick={onCancel} style={styles.cancelButton}>
Cancel
</button>
)}
</div> </div>
); );
} }
@ -473,59 +580,55 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
<div style={styles.iconLarge}>🔐</div> <div style={styles.iconLarge}>🔐</div>
<h2 style={styles.title}>Sign In with CryptID</h2> <h2 style={styles.title}>Sign In with CryptID</h2>
{existingUsers.length > 0 && ( {existingUsers.length > 0 ? (
<div style={styles.existingUsers}> <>
<p style={styles.existingUsersLabel}>Your accounts on this device:</p> <div style={styles.existingUsers}>
<div style={styles.userList}> <p style={styles.existingUsersLabel}>Select your account:</p>
{existingUsers.map((user) => ( <div style={styles.userList}>
<button {existingUsers.map((user) => (
key={user} <button
onClick={() => setUsername(user)} key={user}
style={{ onClick={() => setUsername(user)}
...styles.userButton, style={{
borderColor: username === user ? '#8b5cf6' : '#e5e7eb', ...styles.userButton,
backgroundColor: username === user ? 'rgba(139, 92, 246, 0.1)' : 'transparent', borderColor: username === user ? '#8b5cf6' : '#e5e7eb',
}} backgroundColor: username === user ? 'rgba(139, 92, 246, 0.1)' : 'transparent',
disabled={isLoading} }}
> disabled={isLoading}
<span style={styles.userIcon}>🔑</span> >
<span style={styles.userName}>{user}</span> <span style={styles.userIcon}>🔑</span>
{username === user && <span style={styles.selectedBadge}>Selected</span>} <span style={styles.userName}>{user}</span>
</button> {username === user && <span style={styles.selectedBadge}>Selected</span>}
))} </button>
))}
</div>
</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> </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 <button
onClick={() => { onClick={() => {
setIsRegistering(true); setIsRegistering(true);
@ -533,189 +636,182 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
setUsername(''); setUsername('');
setError(null); setError(null);
}} }}
style={styles.linkButton} style={existingUsers.length > 0 ? { ...styles.linkButton, marginTop: '20px' } : styles.primaryButton}
disabled={isLoading} disabled={isLoading}
> >
Need an account? Create one {existingUsers.length > 0 ? 'Need an account? Create one' : 'Create a CryptID'}
</button> </button>
{onCancel && (
<button onClick={onCancel} style={styles.cancelButton}>
Cancel
</button>
)}
</div> </div>
</div> </div>
); );
}; };
// Styles // Styles - compact layout to fit on one screen (updated 2025-12-12)
const styles: Record<string, React.CSSProperties> = { const styles: Record<string, React.CSSProperties> = {
container: { container: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
padding: '20px', padding: '16px',
maxWidth: '440px', maxWidth: '540px',
margin: '0 auto', margin: '0 auto',
}, },
card: { card: {
width: '100%', width: '100%',
backgroundColor: 'var(--color-panel, #fff)', backgroundColor: 'var(--color-panel, #fff)',
borderRadius: '16px', borderRadius: '16px',
padding: '32px', padding: '20px',
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.1)',
textAlign: 'center', textAlign: 'center',
}, },
errorCard: { errorCard: {
width: '100%', width: '100%',
backgroundColor: '#fef2f2', backgroundColor: '#fef2f2',
borderRadius: '16px', borderRadius: '16px',
padding: '32px', padding: '20px',
textAlign: 'center', textAlign: 'center',
}, },
stepIndicator: { stepIndicator: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: '24px', marginBottom: '16px',
gap: '0', gap: '0',
}, },
stepDot: { stepDot: {
width: '28px', width: '24px',
height: '28px', height: '24px',
borderRadius: '50%', borderRadius: '50%',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
fontSize: '12px', fontSize: '11px',
fontWeight: 600, fontWeight: 600,
color: 'white', color: 'white',
}, },
stepLine: { stepLine: {
width: '40px', width: '32px',
height: '2px', height: '2px',
backgroundColor: '#e5e7eb', backgroundColor: '#e5e7eb',
}, },
iconLarge: { iconLarge: {
fontSize: '48px', fontSize: '36px',
marginBottom: '16px', marginBottom: '12px',
}, },
errorIcon: { errorIcon: {
fontSize: '48px', fontSize: '36px',
marginBottom: '16px', marginBottom: '12px',
}, },
successIcon: { successIcon: {
width: '64px', width: '48px',
height: '64px', height: '48px',
borderRadius: '50%', borderRadius: '50%',
backgroundColor: '#22c55e', backgroundColor: '#22c55e',
color: 'white', color: 'white',
fontSize: '32px', fontSize: '24px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
margin: '0 auto 16px', margin: '0 auto 12px',
}, },
title: { title: {
fontSize: '24px', fontSize: '20px',
fontWeight: 700, fontWeight: 700,
color: 'var(--color-text, #1f2937)', color: 'var(--color-text, #1f2937)',
marginBottom: '8px', marginBottom: '4px',
margin: '0 0 8px 0', margin: '0 0 4px 0',
}, },
subtitle: { subtitle: {
fontSize: '14px', fontSize: '13px',
color: 'var(--color-text-3, #6b7280)', color: 'var(--color-text-3, #6b7280)',
marginBottom: '24px', marginBottom: '16px',
margin: '0 0 24px 0', margin: '0 0 16px 0',
}, },
description: { description: {
fontSize: '14px', fontSize: '13px',
color: '#6b7280', color: '#6b7280',
lineHeight: 1.6, lineHeight: 1.5,
marginBottom: '24px', marginBottom: '16px',
}, },
explainerBox: { explainerBox: {
backgroundColor: 'var(--color-muted-2, #f9fafb)', backgroundColor: 'var(--color-muted-2, #f9fafb)',
borderRadius: '12px', borderRadius: '10px',
padding: '20px', padding: '14px',
marginBottom: '24px', marginBottom: '16px',
textAlign: 'left', textAlign: 'left',
}, },
explainerTitle: { explainerTitle: {
fontSize: '14px', fontSize: '13px',
fontWeight: 600, fontWeight: 600,
color: 'var(--color-text, #1f2937)', color: 'var(--color-text, #1f2937)',
marginBottom: '16px', marginBottom: '12px',
margin: '0 0 16px 0', margin: '0 0 12px 0',
}, },
explainerContent: { explainerContent: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '16px', gap: '10px',
}, },
explainerItem: { explainerItem: {
display: 'flex', display: 'flex',
gap: '12px', gap: '10px',
alignItems: 'flex-start', alignItems: 'flex-start',
}, },
explainerIcon: { explainerIcon: {
fontSize: '20px', fontSize: '16px',
flexShrink: 0, flexShrink: 0,
}, },
explainerText: { explainerText: {
fontSize: '12px', fontSize: '11px',
color: 'var(--color-text-3, #6b7280)', color: 'var(--color-text-3, #6b7280)',
margin: '4px 0 0 0', margin: '2px 0 0 0',
lineHeight: 1.5, lineHeight: 1.4,
}, },
featureList: { featureList: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '8px', gap: '6px',
marginBottom: '24px', marginBottom: '16px',
}, },
featureItem: { featureItem: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '8px', gap: '6px',
fontSize: '13px', fontSize: '12px',
color: 'var(--color-text, #374151)', color: 'var(--color-text, #374151)',
}, },
infoBox: { infoBox: {
display: 'flex', display: 'flex',
gap: '12px', gap: '10px',
padding: '16px', padding: '12px',
backgroundColor: 'rgba(139, 92, 246, 0.1)', backgroundColor: 'rgba(139, 92, 246, 0.1)',
borderRadius: '10px', borderRadius: '8px',
marginBottom: '20px', marginBottom: '14px',
textAlign: 'left', textAlign: 'left',
}, },
infoIcon: { infoIcon: {
fontSize: '20px', fontSize: '16px',
flexShrink: 0, flexShrink: 0,
}, },
infoText: { infoText: {
fontSize: '13px', fontSize: '12px',
color: 'var(--color-text, #374151)', color: 'var(--color-text, #374151)',
margin: 0, margin: 0,
lineHeight: 1.5, lineHeight: 1.4,
}, },
successBox: { successBox: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '12px', gap: '8px',
padding: '16px', padding: '12px',
backgroundColor: 'rgba(34, 197, 94, 0.1)', backgroundColor: 'rgba(34, 197, 94, 0.1)',
borderRadius: '10px', borderRadius: '8px',
marginBottom: '20px', marginBottom: '14px',
}, },
successItem: { successItem: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '10px', gap: '8px',
fontSize: '14px', fontSize: '12px',
color: 'var(--color-text, #374151)', color: 'var(--color-text, #374151)',
}, },
successCheck: { successCheck: {
@ -723,22 +819,22 @@ const styles: Record<string, React.CSSProperties> = {
fontWeight: 600, fontWeight: 600,
}, },
inputGroup: { inputGroup: {
marginBottom: '20px', marginBottom: '14px',
textAlign: 'left', textAlign: 'left',
}, },
label: { label: {
display: 'block', display: 'block',
fontSize: '13px', fontSize: '12px',
fontWeight: 500, fontWeight: 500,
color: 'var(--color-text, #374151)', color: 'var(--color-text, #374151)',
marginBottom: '6px', marginBottom: '4px',
}, },
input: { input: {
width: '100%', width: '100%',
padding: '12px 14px', padding: '10px 12px',
fontSize: '15px', fontSize: '14px',
border: '2px solid var(--color-panel-contrast, #e5e7eb)', border: '2px solid var(--color-panel-contrast, #e5e7eb)',
borderRadius: '10px', borderRadius: '8px',
backgroundColor: 'var(--color-panel, #fff)', backgroundColor: 'var(--color-panel, #fff)',
color: 'var(--color-text, #1f2937)', color: 'var(--color-text, #1f2937)',
outline: 'none', outline: 'none',
@ -746,88 +842,79 @@ const styles: Record<string, React.CSSProperties> = {
boxSizing: 'border-box', boxSizing: 'border-box',
}, },
hint: { hint: {
fontSize: '11px', fontSize: '10px',
color: 'var(--color-text-3, #9ca3af)', color: 'var(--color-text-3, #9ca3af)',
marginTop: '6px', marginTop: '4px',
margin: '6px 0 0 0', margin: '6px 0 0 0',
}, },
error: { error: {
padding: '12px', padding: '10px',
backgroundColor: '#fef2f2', backgroundColor: '#fef2f2',
color: '#dc2626', color: '#dc2626',
borderRadius: '8px', borderRadius: '6px',
fontSize: '13px', fontSize: '12px',
marginBottom: '16px', marginBottom: '12px',
}, },
buttonGroup: { buttonGroup: {
display: 'flex', display: 'flex',
gap: '12px', gap: '10px',
}, },
primaryButton: { primaryButton: {
flex: 1, flex: 1,
padding: '14px 24px', padding: '10px 18px',
fontSize: '15px', fontSize: '14px',
fontWeight: 600, fontWeight: 600,
color: 'white', color: 'white',
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)', background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
border: 'none', border: 'none',
borderRadius: '10px', borderRadius: '8px',
cursor: 'pointer', cursor: 'pointer',
transition: 'transform 0.15s, box-shadow 0.15s', transition: 'transform 0.15s, box-shadow 0.15s',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)', boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)',
}, },
secondaryButton: { secondaryButton: {
flex: 1, flex: 1,
padding: '14px 24px', padding: '10px 18px',
fontSize: '15px', fontSize: '14px',
fontWeight: 500, fontWeight: 500,
color: 'var(--color-text, #374151)', color: 'var(--color-text, #374151)',
backgroundColor: 'var(--color-muted-2, #f3f4f6)', backgroundColor: 'var(--color-muted-2, #f3f4f6)',
border: 'none', border: 'none',
borderRadius: '10px', borderRadius: '8px',
cursor: 'pointer', cursor: 'pointer',
}, },
linkButton: { linkButton: {
marginTop: '16px', marginTop: '12px',
padding: '8px', padding: '6px',
fontSize: '13px', fontSize: '12px',
color: '#8b5cf6', color: '#8b5cf6',
backgroundColor: 'transparent', backgroundColor: 'transparent',
border: 'none', border: 'none',
cursor: 'pointer', cursor: 'pointer',
textDecoration: 'underline', textDecoration: 'underline',
}, },
cancelButton: {
marginTop: '16px',
padding: '8px 16px',
fontSize: '13px',
color: 'var(--color-text-3, #6b7280)',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
},
existingUsers: { existingUsers: {
marginBottom: '20px', marginBottom: '14px',
textAlign: 'left', textAlign: 'left',
}, },
existingUsersLabel: { existingUsersLabel: {
fontSize: '13px', fontSize: '12px',
color: 'var(--color-text-3, #6b7280)', color: 'var(--color-text-3, #6b7280)',
marginBottom: '10px', marginBottom: '8px',
margin: '0 0 10px 0', margin: '0 0 8px 0',
}, },
userList: { userList: {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '8px', gap: '6px',
}, },
userButton: { userButton: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '10px', gap: '8px',
padding: '12px 14px', padding: '10px 12px',
border: '2px solid #e5e7eb', border: '2px solid #e5e7eb',
borderRadius: '10px', borderRadius: '8px',
backgroundColor: 'transparent', backgroundColor: 'transparent',
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s', transition: 'all 0.15s',
@ -835,20 +922,20 @@ const styles: Record<string, React.CSSProperties> = {
textAlign: 'left', textAlign: 'left',
}, },
userIcon: { userIcon: {
fontSize: '18px', fontSize: '16px',
}, },
userName: { userName: {
flex: 1, flex: 1,
fontSize: '14px', fontSize: '13px',
fontWeight: 500, fontWeight: 500,
color: 'var(--color-text, #374151)', color: 'var(--color-text, #374151)',
}, },
selectedBadge: { selectedBadge: {
fontSize: '11px', fontSize: '10px',
padding: '2px 8px', padding: '2px 6px',
backgroundColor: '#8b5cf6', backgroundColor: '#8b5cf6',
color: 'white', color: 'white',
borderRadius: '10px', borderRadius: '8px',
fontWeight: 500, fontWeight: 500,
}, },
}; };

View File

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

View File

@ -17,7 +17,7 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as d3 from 'd3'; 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'; import { UserSearchModal } from './UserSearchModal';
// ============================================================================= // =============================================================================
@ -32,6 +32,9 @@ interface NetworkGraphMinimapProps {
onConnect: (userId: string, trustLevel?: TrustLevel) => Promise<void>; onConnect: (userId: string, trustLevel?: TrustLevel) => Promise<void>;
onDisconnect?: (connectionId: string) => Promise<void>; onDisconnect?: (connectionId: string) => Promise<void>;
onNodeClick?: (node: GraphNode) => void; onNodeClick?: (node: GraphNode) => void;
onGoToUser?: (node: GraphNode) => void;
onFollowUser?: (node: GraphNode) => void;
onOpenProfile?: (node: GraphNode) => void;
onEdgeClick?: (edge: GraphEdge) => void; onEdgeClick?: (edge: GraphEdge) => void;
onExpandClick?: () => void; onExpandClick?: () => void;
width?: number; width?: number;
@ -132,25 +135,6 @@ const getStyles = (isDarkMode: boolean) => ({
collapsedIcon: { collapsedIcon: {
fontSize: '20px', 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, onConnect,
onDisconnect, onDisconnect,
onNodeClick, onNodeClick,
onGoToUser,
onFollowUser,
onOpenProfile,
onEdgeClick, onEdgeClick,
onExpandClick, onExpandClick,
width = 240, width = 240,
@ -183,13 +170,6 @@ export function NetworkGraphMinimap({
// Get theme-aware styles // Get theme-aware styles
const styles = React.useMemo(() => getStyles(isDarkMode), [isDarkMode]); 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 // Initialize and update the D3 simulation
useEffect(() => { useEffect(() => {
if (!svgRef.current || isCollapsed || nodes.length === 0) return; 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 // Helper to get node color - uses the user's profile/presence color
// Priority: current user (purple) > anonymous (grey) > trust level > unconnected (white)
const getNodeColor = (d: SimulationNode) => { const getNodeColor = (d: SimulationNode) => {
if (d.isCurrentUser) { // Use room presence color (user's profile color) if available
return '#4f46e5'; // Current user is always purple if (d.roomPresenceColor) {
return d.roomPresenceColor;
} }
// Anonymous users are grey // Use avatar color as fallback
if (d.isAnonymous) { if (d.avatarColor) {
return TRUST_LEVEL_COLORS.anonymous; return d.avatarColor;
} }
// If in room and has presence color, use it for the stroke/ring instead // Default grey for users without a color
// (we still use trust level for fill to maintain visual consistency) return '#9ca3af';
// 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;
}; };
// Create nodes // Create nodes
@ -358,15 +315,13 @@ export function NetworkGraphMinimap({
.join('circle') .join('circle')
.attr('r', d => d.isCurrentUser ? 8 : 6) .attr('r', d => d.isCurrentUser ? 8 : 6)
.attr('fill', d => getNodeColor(d)) .attr('fill', d => getNodeColor(d))
.attr('stroke', d => getNodeStroke(d))
.attr('stroke-width', d => getNodeStrokeWidth(d))
.style('cursor', 'pointer') .style('cursor', 'pointer')
.on('mouseenter', (event, d) => { .on('mouseenter', (event, d) => {
const rect = svgRef.current!.getBoundingClientRect(); const rect = svgRef.current!.getBoundingClientRect();
setTooltip({ setTooltip({
x: event.clientX - rect.left, x: event.clientX - rect.left,
y: event.clientY - rect.top, y: event.clientY - rect.top,
text: `${d.displayName || d.username}${d.isAnonymous ? ' (anonymous)' : ''}`, text: d.displayName || d.username,
}); });
}) })
.on('mouseleave', () => { .on('mouseleave', () => {
@ -374,12 +329,12 @@ export function NetworkGraphMinimap({
}) })
.on('click', (event, d) => { .on('click', (event, d) => {
event.stopPropagation(); event.stopPropagation();
// Don't show popup for current user or anonymous users // Don't show popup for current user
if (d.isCurrentUser || d.isAnonymous) { if (d.isCurrentUser) {
if (onNodeClick) onNodeClick(d); if (onNodeClick) onNodeClick(d);
return; return;
} }
// Show connection popup // Show dropdown menu for all other users
const rect = svgRef.current!.getBoundingClientRect(); const rect = svgRef.current!.getBoundingClientRect();
setSelectedNode({ setSelectedNode({
node: d, node: d,
@ -502,187 +457,142 @@ export function NetworkGraphMinimap({
</div> </div>
)} )}
{/* Connection popup when clicking a node */} {/* User action dropdown menu when clicking a node */}
{selectedNode && !selectedNode.node.isCurrentUser && !selectedNode.node.isAnonymous && ( {selectedNode && !selectedNode.node.isCurrentUser && (
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
left: Math.min(selectedNode.x, width - 140), left: Math.min(selectedNode.x, width - 160),
top: Math.max(selectedNode.y - 80, 10), top: Math.max(selectedNode.y - 10, 10),
backgroundColor: 'white', backgroundColor: isDarkMode ? '#1e1e2e' : 'white',
borderRadius: '8px', borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', boxShadow: isDarkMode ? '0 4px 12px rgba(0, 0, 0, 0.4)' : '0 4px 12px rgba(0, 0, 0, 0.15)',
padding: '8px', padding: '6px',
zIndex: 1002, 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()} onClick={(e) => e.stopPropagation()}
> >
<div style={{ fontSize: '11px', fontWeight: 600, marginBottom: '6px', color: '#1a1a2e' }}> {/* Connect option - only for non-anonymous users */}
{selectedNode.node.displayName || selectedNode.node.username} {!selectedNode.node.isAnonymous && (
</div> <button
<div style={{ fontSize: '10px', color: '#666', marginBottom: '8px' }}> onClick={async () => {
@{selectedNode.node.username} setIsConnecting(true);
</div> try {
const userId = selectedNode.node.username || selectedNode.node.id;
{/* Connection actions */} await onConnect(userId, 'connected');
{selectedNode.node.trustLevelTo ? ( } catch (err) {
// Already connected - show trust level options console.error('Failed to connect:', err);
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}> }
<button setSelectedNode(null);
onClick={async () => { setIsConnecting(false);
// Toggle trust level }}
const newLevel = selectedNode.node.trustLevelTo === 'trusted' ? 'connected' : 'trusted'; disabled={isConnecting}
setIsConnecting(true); style={{
// This would need updateTrustLevel function passed as prop width: '100%',
// For now, just close the popup padding: '8px 10px',
setSelectedNode(null); fontSize: '11px',
setIsConnecting(false); backgroundColor: 'transparent',
}} color: isDarkMode ? '#fbbf24' : '#92400e',
disabled={isConnecting} border: 'none',
style={{ borderRadius: '4px',
padding: '6px 10px', cursor: 'pointer',
fontSize: '10px', textAlign: 'left',
backgroundColor: selectedNode.node.trustLevelTo === 'trusted' ? '#fef3c7' : '#d1fae5', display: 'flex',
color: selectedNode.node.trustLevelTo === 'trusted' ? '#92400e' : '#065f46', alignItems: 'center',
border: 'none', gap: '8px',
borderRadius: '4px', }}
cursor: 'pointer', 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'}
> >
{selectedNode.node.trustLevelTo === 'trusted' ? 'Downgrade to Connected' : 'Upgrade to Trusted'} <span>🔗</span> Connect with {selectedNode.node.displayName || selectedNode.node.username}
</button> </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>
)} )}
<button {/* Navigate option - only for in-room users */}
onClick={() => setSelectedNode(null)} {selectedNode.node.isInRoom && onGoToUser && (
style={{ <button
marginTop: '6px', onClick={() => {
width: '100%', onGoToUser(selectedNode.node);
padding: '4px', setSelectedNode(null);
fontSize: '9px', }}
backgroundColor: 'transparent', style={{
color: '#666', width: '100%',
border: 'none', padding: '8px 10px',
cursor: 'pointer', fontSize: '11px',
}} backgroundColor: 'transparent',
> color: isDarkMode ? '#a0a0ff' : '#4f46e5',
Cancel border: 'none',
</button> borderRadius: '4px',
</div> cursor: 'pointer',
)} textAlign: 'left',
</div> 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}> {/* Screenfollow option - only for in-room users */}
<div style={styles.stat} title="Users in this room"> {selectedNode.node.isInRoom && onFollowUser && (
<div style={{ ...styles.statDot, backgroundColor: '#4f46e5' }} /> <button
<span>{inRoomCount}</span> onClick={() => {
</div> onFollowUser(selectedNode.node);
<div style={styles.stat} title="Trusted (edit access)"> setSelectedNode(null);
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.trusted }} /> }}
<span>{trustedCount}</span> style={{
</div> width: '100%',
<div style={styles.stat} title="Connected (view access)"> padding: '8px 10px',
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.connected }} /> fontSize: '11px',
<span>{connectedCount}</span> backgroundColor: 'transparent',
</div> color: isDarkMode ? '#60a5fa' : '#2563eb',
<div style={styles.stat} title="Unconnected"> border: 'none',
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.unconnected, border: '1px solid #e5e7eb' }} /> borderRadius: '4px',
<span>{unconnectedCount}</span> cursor: 'pointer',
</div> textAlign: 'left',
{anonymousCount > 0 && ( display: 'flex',
<div style={styles.stat} title="Anonymous"> alignItems: 'center',
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.anonymous }} /> gap: '8px',
<span>{anonymousCount}</span> }}
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>
)} )}
</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) => { collaborators.forEach((c: any) => {
participants.push({ participants.push({
id: c.id || c.userId || c.instanceId, id: c.userId || c.id,
username: c.userName || 'Anonymous', username: c.userName || 'Anonymous',
color: c.color, color: c.color,
}); });
@ -112,6 +112,58 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
console.log('Node clicked:', node); 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 // Handle edge click
const handleEdgeClick = useCallback((edge: GraphEdge) => { const handleEdgeClick = useCallback((edge: GraphEdge) => {
setSelectedEdge(edge); setSelectedEdge(edge);
@ -156,6 +208,9 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
onConnect={handleConnect} onConnect={handleConnect}
onDisconnect={handleDisconnect} onDisconnect={handleDisconnect}
onNodeClick={handleNodeClick} onNodeClick={handleNodeClick}
onGoToUser={handleGoToUser}
onFollowUser={handleFollowUser}
onOpenProfile={handleOpenProfile}
onEdgeClick={handleEdgeClick} onEdgeClick={handleEdgeClick}
onExpandClick={handleExpand} onExpandClick={handleExpand}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}

View File

@ -155,6 +155,45 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
try { try {
setState(prev => ({ ...prev, isLoading: !prev.nodes.length })); 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 // Fetch graph, optionally scoped to room
let graph: NetworkGraph; let graph: NetworkGraph;
try { try {
@ -165,7 +204,10 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
} }
} catch (apiError: any) { } catch (apiError: any) {
// If API call fails (e.g., 401 Unauthorized), fall back to showing room participants // 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 => ({ const fallbackNodes: GraphNode[] = roomParticipants.map(participant => ({
id: participant.id, id: participant.id,
username: participant.username, username: participant.username,

View File

@ -105,7 +105,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const result = await AuthService.login(username); const result = await AuthService.login(username);
if (result.success && result.session) { 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 // Save session to localStorage if authenticated
if (result.session.authed && result.session.username) { 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); const result = await AuthService.register(username);
if (result.success && result.session) { 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 // Save session to localStorage if authenticated
if (result.session.authed && result.session.username) { if (result.session.authed && result.session.username) {
@ -178,7 +192,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
loading: false, loading: false,
backupCreated: null, backupCreated: null,
obsidianVaultPath: undefined, 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> => { const fetchBoardPermission = useCallback(async (boardId: string): Promise<PermissionLevel> => {
// Check cache first (but only if no access token - token changes permissions) // Check cache first (but only if no access token - token changes permissions)
if (!accessToken && session.boardPermissions?.[boardId]) { if (!accessToken && session.boardPermissions?.[boardId]) {
console.log('🔐 Using cached permission for board:', boardId, session.boardPermissions[boardId]);
return session.boardPermissions[boardId]; return session.boardPermissions[boardId];
} }
@ -224,13 +242,24 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
let publicKeyUsed: string | null = null;
if (session.authed && session.username) { if (session.authed && session.username) {
const publicKey = crypto.getPublicKey(session.username); const publicKey = crypto.getPublicKey(session.username);
if (publicKey) { if (publicKey) {
headers['X-CryptID-PublicKey'] = 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 // Build URL with optional access token
let url = `${WORKER_URL}/boards/${boardId}/permission`; let url = `${WORKER_URL}/boards/${boardId}/permission`;
if (accessToken) { if (accessToken) {
@ -245,8 +274,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (!response.ok) { if (!response.ok) {
console.error('Failed to fetch board permission:', response.status); console.error('Failed to fetch board permission:', response.status);
// Default to 'view' for unauthenticated (secure by default) // Default to 'edit' for authenticated users, 'view' for unauthenticated
return 'view'; const defaultPermission: PermissionLevel = session.authed ? 'edit' : 'view';
console.log('🔐 Using default permission (API failed):', defaultPermission);
return defaultPermission;
} }
const data = await response.json() as { const data = await response.json() as {
@ -254,27 +285,47 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
isOwner: boolean; isOwner: boolean;
boardExists: boolean; boardExists: boolean;
grantedByToken?: boolean; grantedByToken?: boolean;
isExplicitPermission?: boolean; // Whether this permission was explicitly set
}; };
// Debug: Log what we received
console.log('🔐 Permission response:', data);
if (data.grantedByToken) { if (data.grantedByToken) {
console.log('🔓 Permission granted via access token:', data.permission); 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 // Cache the permission
setSessionState(prev => ({ setSessionState(prev => ({
...prev, ...prev,
currentBoardPermission: data.permission, currentBoardPermission: effectivePermission,
boardPermissions: { boardPermissions: {
...prev.boardPermissions, ...prev.boardPermissions,
[boardId]: data.permission, [boardId]: effectivePermission,
}, },
})); }));
return data.permission; return effectivePermission;
} catch (error) { } catch (error) {
console.error('Error fetching board permission:', error); console.error('Error fetching board permission:', error);
// Default to 'view' (secure by default) // Default to 'edit' for authenticated users, 'view' for unauthenticated
return 'view'; const defaultPermission: PermissionLevel = session.authed ? 'edit' : 'view';
console.log('🔐 Using default permission (error):', defaultPermission);
return defaultPermission;
} }
}, [session.authed, session.username, session.boardPermissions, accessToken]); }, [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 { .anonymous-viewer-banner {
position: fixed; position: fixed;
bottom: 20px; top: 56px;
left: 50%; right: 10px;
transform: translateX(-50%); z-index: 100000;
z-index: 10000;
max-width: 600px; max-width: 200px;
width: calc(100% - 40px); width: auto;
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%); background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
border: 1px solid rgba(139, 92, 246, 0.3); border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: 16px; border-radius: 8px;
box-shadow: box-shadow:
0 20px 60px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.2),
0 0 40px rgba(139, 92, 246, 0.15); 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 { from {
opacity: 0; opacity: 0;
transform: translateX(-50%) translateY(20px); transform: translateY(-8px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateX(-50%) translateY(0); transform: translateY(0);
} }
} }
@ -35,22 +34,29 @@
background: linear-gradient(135deg, #2d1f3d 0%, #3d2d54 100%); background: linear-gradient(135deg, #2d1f3d 0%, #3d2d54 100%);
border-color: rgba(236, 72, 153, 0.4); border-color: rgba(236, 72, 153, 0.4);
box-shadow: box-shadow:
0 20px 60px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.25),
0 0 40px rgba(236, 72, 153, 0.2); 0 0 20px rgba(236, 72, 153, 0.15);
} }
.banner-content { .banner-content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
padding-top: 8px;
}
.banner-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 16px; gap: 8px;
padding: 20px;
} }
.banner-icon { .banner-icon {
flex-shrink: 0; flex-shrink: 0;
width: 48px; width: 24px;
height: 48px; height: 24px;
border-radius: 12px; border-radius: 6px;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
display: flex; display: flex;
align-items: center; align-items: center;
@ -58,6 +64,11 @@
color: white; color: white;
} }
.banner-icon svg {
width: 14px;
height: 14px;
}
.edit-triggered .banner-icon { .edit-triggered .banner-icon {
background: linear-gradient(135deg, #ec4899 0%, #a855f7 100%); background: linear-gradient(135deg, #ec4899 0%, #a855f7 100%);
} }
@ -68,10 +79,10 @@
} }
.banner-headline { .banner-headline {
margin: 0 0 8px 0; margin: 0 0 2px 0;
font-size: 16px; font-size: 11px;
color: #f0f0f0; color: #f0f0f0;
line-height: 1.4; line-height: 1.3;
} }
.banner-headline strong { .banner-headline strong {
@ -80,75 +91,27 @@
.banner-summary { .banner-summary {
margin: 0; margin: 0;
font-size: 14px; font-size: 10px;
color: #a0a0b0; color: #a0a0b0;
line-height: 1.5; line-height: 1.3;
}
.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;
} }
.banner-actions { .banner-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 8px; gap: 6px;
flex-shrink: 0; width: 100%;
} }
.banner-signup-btn { .banner-signup-btn {
padding: 10px 20px; flex: 1;
font-size: 14px; padding: 5px 10px;
font-size: 10px;
font-weight: 600; font-weight: 600;
color: white; color: white;
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
border: none; border: none;
border-radius: 8px; border-radius: 5px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
white-space: nowrap; white-space: nowrap;
@ -156,7 +119,7 @@
.banner-signup-btn:hover { .banner-signup-btn:hover {
background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%); 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); transform: translateY(-1px);
} }
@ -166,55 +129,47 @@
.edit-triggered .banner-signup-btn:hover { .edit-triggered .banner-signup-btn:hover {
background: linear-gradient(135deg, #db2777 0%, #9333ea 100%); 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 { .banner-dismiss-btn {
position: absolute;
top: 4px;
right: 4px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: 18px;
height: 32px; height: 18px;
padding: 0; padding: 0;
color: #808090; color: #808090;
background: transparent; background: rgba(255, 255, 255, 0.08);
border: none; border: none;
border-radius: 6px; border-radius: 50%;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
z-index: 1;
}
.banner-dismiss-btn svg {
width: 10px;
height: 10px;
} }
.banner-dismiss-btn:hover { .banner-dismiss-btn:hover {
color: #f0f0f0; color: #f0f0f0;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.15);
}
.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);
} }
.banner-edit-notice { .banner-edit-notice {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 4px;
padding: 12px 20px; padding: 5px 10px;
background: rgba(236, 72, 153, 0.1); background: rgba(236, 72, 153, 0.1);
border-top: 1px solid rgba(236, 72, 153, 0.2); border-top: 1px solid rgba(236, 72, 153, 0.2);
border-radius: 0 0 16px 16px; border-radius: 0 0 8px 8px;
font-size: 13px; font-size: 9px;
color: #f472b6; color: #f472b6;
} }
@ -264,8 +219,8 @@
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-color: rgba(139, 92, 246, 0.2); border-color: rgba(139, 92, 246, 0.2);
box-shadow: box-shadow:
0 20px 60px rgba(0, 0, 0, 0.1), 0 8px 24px rgba(0, 0, 0, 0.08),
0 0 40px rgba(139, 92, 246, 0.1); 0 0 16px rgba(139, 92, 246, 0.08);
} }
.banner-headline { .banner-headline {
@ -276,48 +231,48 @@
color: #1e1e2e; color: #1e1e2e;
} }
.banner-summary, .banner-summary {
.banner-details p,
.cryptid-benefits li {
color: #606080; color: #606080;
} }
.banner-dismiss-btn { .banner-dismiss-btn {
color: #606080; color: #606080;
background: rgba(0, 0, 0, 0.05);
} }
.banner-dismiss-btn:hover { .banner-dismiss-btn:hover {
color: #2d2d44; color: #2d2d44;
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.1);
} }
} }
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 640px) { @media (max-width: 640px) {
.anonymous-viewer-banner { .anonymous-viewer-banner {
bottom: 10px; top: 56px;
max-width: none; right: 8px;
width: calc(100% - 20px); max-width: 180px;
border-radius: 12px;
} }
.banner-content { .banner-content {
flex-direction: column; padding: 8px;
padding: 16px;
} }
.banner-icon { .banner-icon {
width: 40px; width: 20px;
height: 40px; height: 20px;
} }
.banner-actions { .banner-icon svg {
flex-direction: row; width: 12px;
width: 100%; height: 12px;
margin-top: 12px;
} }
.banner-signup-btn { .banner-headline {
flex: 1; font-size: 10px;
}
.banner-summary {
font-size: 9px;
} }
} }

View File

@ -5,6 +5,10 @@ import { loadSession, saveSession, clearStoredSession } from './sessionPersisten
export class AuthService { export class AuthService {
/** /**
* Initialize the authentication state * 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<{ static async initialize(): Promise<{
session: Session; session: Session;
@ -13,8 +17,12 @@ export class AuthService {
const storedSession = loadSession(); const storedSession = loadSession();
let session: Session; let session: Session;
if (storedSession && storedSession.authed && storedSession.username) { // Only restore session if ALL conditions are met:
// Restore existing session // 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 = { session = {
username: storedSession.username, username: storedSession.username,
authed: true, authed: true,
@ -23,14 +31,18 @@ export class AuthService {
obsidianVaultPath: storedSession.obsidianVaultPath, obsidianVaultPath: storedSession.obsidianVaultPath,
obsidianVaultName: storedSession.obsidianVaultName obsidianVaultName: storedSession.obsidianVaultName
}; };
console.log('🔐 Restored authenticated session for:', storedSession.username);
} else { } 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 = { session = {
username: '', username: '',
authed: false, authed: false,
loading: false, loading: false,
backupCreated: null backupCreated: null
}; };
console.log('🔐 No valid session found - user is anonymous');
} }
return { session }; return { session };

View File

@ -46,14 +46,21 @@ export const loadSession = (): StoredSession | null => {
try { try {
const stored = localStorage.getItem(SESSION_STORAGE_KEY); const stored = localStorage.getItem(SESSION_STORAGE_KEY);
if (!stored) { if (!stored) {
console.log('🔐 loadSession: No stored session found');
return null; return null;
} }
const parsed = JSON.parse(stored) as StoredSession; 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) // Check if session is not too old (7 days)
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
if (Date.now() - parsed.timestamp > maxAge) { if (Date.now() - parsed.timestamp > maxAge) {
console.log('🔐 loadSession: Session expired, removing');
localStorage.removeItem(SESSION_STORAGE_KEY); localStorage.removeItem(SESSION_STORAGE_KEY);
return null; 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 { function getCurrentUserId(): string | null {
try { 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) { if (sessionStr) {
const session = JSON.parse(sessionStr); const session = JSON.parse(sessionStr);
if (session.authed && session.username) { if (session.authed && session.username) {

View File

@ -12,7 +12,7 @@ import { EmbedShape } from "@/shapes/EmbedShapeUtil"
import { EmbedTool } from "@/tools/EmbedTool" import { EmbedTool } from "@/tools/EmbedTool"
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil" import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
import { MarkdownTool } from "@/tools/MarkdownTool" import { MarkdownTool } from "@/tools/MarkdownTool"
import { defaultShapeUtils, defaultBindingUtils } from "tldraw" import { defaultShapeUtils, defaultBindingUtils, defaultShapeTools } from "tldraw"
import { components } from "@/ui/components" import { components } from "@/ui/components"
import { overrides } from "@/ui/overrides" import { overrides } from "@/ui/overrides"
import { unfurlBookmarkUrl } from "../utils/unfurlBookmarkUrl" import { unfurlBookmarkUrl } from "../utils/unfurlBookmarkUrl"
@ -71,8 +71,8 @@ import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
import { GestureTool } from "@/GestureTool" import { GestureTool } from "@/GestureTool"
import { CmdK } from "@/CmdK" import { CmdK } from "@/CmdK"
import { setupMultiPasteHandler } from "@/utils/multiPasteHandler" import { setupMultiPasteHandler } from "@/utils/multiPasteHandler"
import { ConnectionStatusIndicator } from "@/components/ConnectionStatusIndicator"
import AnonymousViewerBanner from "@/components/auth/AnonymousViewerBanner" import AnonymousViewerBanner from "@/components/auth/AnonymousViewerBanner"
import { ConnectionProvider } from "@/context/ConnectionContext"
import { PermissionLevel } from "@/lib/auth/types" import { PermissionLevel } from "@/lib/auth/types"
import "@/css/anonymous-banner.css" import "@/css/anonymous-banner.css"
@ -281,7 +281,28 @@ export function Board() {
const [permissionLoading, setPermissionLoading] = useState(true) const [permissionLoading, setPermissionLoading] = useState(true)
const [showEditPrompt, setShowEditPrompt] = useState(false) 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(() => { useEffect(() => {
let mounted = true let mounted = true
@ -291,6 +312,7 @@ export function Board() {
const perm = await fetchBoardPermission(roomId) const perm = await fetchBoardPermission(roomId)
if (mounted) { if (mounted) {
setPermission(perm) setPermission(perm)
console.log('🔐 Permission fetched:', perm)
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch permission:', error) console.error('Failed to fetch permission:', error)
@ -312,8 +334,46 @@ export function Board() {
} }
}, [roomId, fetchBoardPermission, session.authed]) }, [roomId, fetchBoardPermission, session.authed])
// Check if user can edit (either has edit/admin permission, or is authenticated with default edit access) // Check if user can edit
const isReadOnly = permission === 'view' || (!session.authed && !permission) // 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 // Handler for when user tries to edit in read-only mode
const handleEditAttempt = () => { const handleEditAttempt = () => {
@ -916,8 +976,18 @@ export function Board() {
let lastContentHash = ''; let lastContentHash = '';
let timeoutId: NodeJS.Timeout; let timeoutId: NodeJS.Timeout;
let idleCallbackId: number | null = null;
const captureScreenshot = async () => { 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 currentShapes = editor.getCurrentPageShapes();
const currentContentHash = currentShapes.length > 0 const currentContentHash = currentShapes.length > 0
? currentShapes.map(shape => `${shape.id}-${shape.type}`).sort().join('|') ? currentShapes.map(shape => `${shape.id}-${shape.type}`).sort().join('|')
@ -926,7 +996,19 @@ export function Board() {
// Only capture if content actually changed // Only capture if content actually changed
if (currentContentHash !== lastContentHash) { if (currentContentHash !== lastContentHash) {
lastContentHash = currentContentHash; 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);
}
} }
}; };
@ -935,13 +1017,17 @@ export function Board() {
// Clear existing timeout // Clear existing timeout
if (timeoutId) clearTimeout(timeoutId); if (timeoutId) clearTimeout(timeoutId);
// Set new timeout for debounced screenshot capture // Set new timeout for debounced screenshot capture (5 seconds instead of 3)
timeoutId = setTimeout(captureScreenshot, 3000); // Longer debounce gives users more time for continuous operations
timeoutId = setTimeout(captureScreenshot, 5000);
}, { source: "user", scope: "document" }); }, { source: "user", scope: "document" });
return () => { return () => {
unsubscribe(); unsubscribe();
if (timeoutId) clearTimeout(timeoutId); if (timeoutId) clearTimeout(timeoutId);
if (idleCallbackId !== null && 'cancelIdleCallback' in window) {
cancelIdleCallback(idleCallbackId);
}
}; };
}, [editor, roomId, store.store]); }, [editor, roomId, store.store]);
@ -1071,143 +1157,149 @@ export function Board() {
return ( return (
<AutomergeHandleProvider handle={automergeHandle}> <AutomergeHandleProvider handle={automergeHandle}>
<div style={{ position: "fixed", inset: 0 }}> <ConnectionProvider connectionState={connectionState} isNetworkOnline={isNetworkOnline}>
<Tldraw <div style={{ position: "fixed", inset: 0 }}>
store={store.store} <Tldraw
user={user} key={`tldraw-${authChangeCount}-${session.authed ? 'authed' : 'anon'}-${session.username || 'guest'}`}
shapeUtils={[...defaultShapeUtils, ...customShapeUtils]} store={store.store}
tools={customTools} user={user}
components={components} shapeUtils={[...defaultShapeUtils, ...customShapeUtils]}
overrides={{ tools={[...defaultShapeTools, ...customTools]}
...overrides, components={components}
actions: (editor, actions, helpers) => { overrides={{
const customActions = overrides.actions?.(editor, actions, helpers) ?? {} ...overrides,
return { actions: (editor, actions, helpers) => {
...actions, const customActions = overrides.actions?.(editor, actions, helpers) ?? {}
...customActions, return {
...actions,
...customActions,
}
} }
} }}
}} cameraOptions={{
cameraOptions={{ zoomSteps: [
zoomSteps: [ 0.001, // Min zoom
0.001, // Min zoom 0.0025,
0.0025, 0.005,
0.005, 0.01,
0.01, 0.025,
0.025, 0.05,
0.05, 0.1,
0.1, 0.25,
0.25, 0.5,
0.5, 1,
1, 2,
2, 4,
4, 8,
8, 16,
16, 32,
32, 64, // Max zoom
64, // Max zoom ],
], }}
}} onMount={(editor) => {
onMount={(editor) => { setEditor(editor)
setEditor(editor) editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl) editor.setCurrentTool("hand")
editor.setCurrentTool("hand") setInitialCameraFromUrl(editor)
setInitialCameraFromUrl(editor) handleInitialPageLoad(editor)
handleInitialPageLoad(editor) registerPropagators(editor, [
registerPropagators(editor, [ TickPropagator,
TickPropagator, ChangePropagator,
ChangePropagator, ClickPropagator,
ClickPropagator, ])
])
// Clean up corrupted shapes that cause "No nearest point found" errors // Clean up corrupted shapes that cause "No nearest point found" errors
// This typically happens with draw/line shapes that have no points // This typically happens with draw/line shapes that have no points
try { try {
const allShapes = editor.getCurrentPageShapes() const allShapes = editor.getCurrentPageShapes()
const corruptedShapeIds: TLShapeId[] = [] const corruptedShapeIds: TLShapeId[] = []
for (const shape of allShapes) { for (const shape of allShapes) {
// Check draw and line shapes for missing/empty segments // Check draw and line shapes for missing/empty segments
if (shape.type === 'draw' || shape.type === 'line') { if (shape.type === 'draw' || shape.type === 'line') {
const props = shape.props as any const props = shape.props as any
// Draw shapes need segments with points // Draw shapes need segments with points
if (shape.type === 'draw') { if (shape.type === 'draw') {
if (!props.segments || props.segments.length === 0) { if (!props.segments || props.segments.length === 0) {
corruptedShapeIds.push(shape.id) corruptedShapeIds.push(shape.id)
continue 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 // Line shapes need points
const hasPoints = props.segments.some((seg: any) => seg.points && seg.points.length > 0) if (shape.type === 'line') {
if (!hasPoints) { if (!props.points || Object.keys(props.points).length === 0) {
corruptedShapeIds.push(shape.id) 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) { if (corruptedShapeIds.length > 0) {
console.warn(`🧹 Removing ${corruptedShapeIds.length} corrupted shapes (draw/line with no points)`) console.warn(`🧹 Removing ${corruptedShapeIds.length} corrupted shapes (draw/line with no points)`)
editor.deleteShapes(corruptedShapeIds) editor.deleteShapes(corruptedShapeIds)
} }
} 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) { } catch (error) {
console.error('Error setting initial TLDraw user preferences:', error); console.error('Error cleaning up corrupted shapes:', 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
// Set read-only mode based on permission // Set user preferences immediately if user is authenticated
if (isReadOnly) { if (session.authed && session.username) {
editor.updateInstanceState({ isReadonly: true }) try {
console.log('🔒 Board is in read-only mode for this user') editor.user.updateUserPreferences({
} id: session.username,
}} name: session.username,
> });
<CmdK /> } catch (error) {
<PrivateWorkspaceManager /> console.error('Error setting initial TLDraw user preferences:', error);
<VisibilityChangeManager /> }
</Tldraw> } else {
<ConnectionStatusIndicator // Set default user preferences when not authenticated
connectionState={connectionState} try {
isNetworkOnline={isNetworkOnline} editor.user.updateUserPreferences({
/> id: 'user-1',
{/* Anonymous viewer banner - show for unauthenticated users or when edit was attempted */} name: 'User 1',
{(!session.authed || showEditPrompt) && ( });
<AnonymousViewerBanner } catch (error) {
onAuthenticated={handleAuthenticated} console.error('Error setting default TLDraw user preferences:', error);
triggeredByEdit={showEditPrompt} }
/> }
)} initializeGlobalCollections(editor, collections)
</div> // 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 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> </AutomergeHandleProvider>
) )
} }

View File

@ -6,9 +6,12 @@ import {
DefaultMainMenuContent, DefaultMainMenuContent,
useEditor, useEditor,
} from "tldraw"; } from "tldraw";
import { useState } from "react";
import { MiroImportDialog } from "@/components/MiroImportDialog";
export function CustomMainMenu() { export function CustomMainMenu() {
const editor = useEditor() const editor = useEditor()
const [showMiroImport, setShowMiroImport] = useState(false)
const importJSON = (editor: Editor) => { const importJSON = (editor: Editor) => {
const input = document.createElement("input"); const input = document.createElement("input");
@ -727,29 +730,42 @@ export function CustomMainMenu() {
}; };
return ( return (
<DefaultMainMenu> <>
<DefaultMainMenuContent /> <DefaultMainMenu>
<TldrawUiMenuItem <DefaultMainMenuContent />
id="export" <TldrawUiMenuItem
label="Export JSON" id="export"
icon="external-link" label="Export JSON"
readonlyOk icon="external-link"
onSelect={() => exportJSON(editor)} 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 if (!isReady) return null
// Only show custom tools for authenticated users
const isAuthenticated = session.authed
return ( return (
<> <>
<DefaultToolbar> <DefaultToolbar>
<DefaultToolbarContent /> <DefaultToolbarContent />
{tools["VideoChat"] && ( {/* Custom tools - only shown when authenticated */}
<TldrawUiMenuItem {isAuthenticated && (
{...tools["VideoChat"]} <>
icon="video" {tools["VideoChat"] && (
label="Video Chat" <TldrawUiMenuItem
isSelected={tools["VideoChat"].id === editor.getCurrentToolId()} {...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> </DefaultToolbar>
{/* Fathom Meetings Panel */} {/* Fathom Meetings Panel */}

View File

@ -5,6 +5,7 @@ import { useWebSpeechTranscription } from "@/hooks/useWebSpeechTranscription"
import { ToolSchema } from "@/lib/toolSchema" import { ToolSchema } from "@/lib/toolSchema"
import { spawnTools, spawnTool } from "@/utils/toolSpawner" import { spawnTools, spawnTool } from "@/utils/toolSpawner"
import { TransformCommand } from "@/utils/selectionTransforms" import { TransformCommand } from "@/utils/selectionTransforms"
import { useConnectionStatus } from "@/context/ConnectionContext"
// Copy icon component // Copy icon component
const CopyIcon = () => ( const CopyIcon = () => (
@ -803,9 +804,92 @@ interface ConversationMessage {
executedTransform?: TransformCommand 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() { export function MycelialIntelligenceBar() {
const editor = useEditor() const editor = useEditor()
const isDark = useDarkMode() const isDark = useDarkMode()
const { connectionState, isNetworkOnline } = useConnectionStatus()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const chatContainerRef = useRef<HTMLDivElement>(null) const chatContainerRef = useRef<HTMLDivElement>(null)
const containerRef = 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 hasTldrawDialog = document.querySelector('[data-state="open"][role="dialog"]') !== null
const hasAuthModal = document.querySelector('.auth-modal-overlay') !== null const hasAuthModal = document.querySelector('.auth-modal-overlay') !== null
const hasPopup = document.querySelector('.profile-popup') !== 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 // Initial check
@ -1351,8 +1438,9 @@ export function MycelialIntelligenceBar() {
}, []) }, [])
// Height: taller when showing suggestion chips (single tool or 2+ selected) // 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 showSuggestions = selectedToolInfo || (selectionInfo && selectionInfo.count > 1)
const collapsedHeight = showSuggestions ? 76 : 48 const collapsedHeight = showSuggestions ? 68 : 40
const maxExpandedHeight = isMobile ? 300 : 400 const maxExpandedHeight = isMobile ? 300 : 400
// Responsive width: full width on mobile, percentage on narrow, fixed on desktop // Responsive width: full width on mobile, percentage on narrow, fixed on desktop
const barWidth = isMobile ? 'calc(100% - 20px)' : isNarrow ? 'calc(100% - 120px)' : 520 const barWidth = isMobile ? 'calc(100% - 20px)' : isNarrow ? 'calc(100% - 120px)' : 520
@ -1414,8 +1502,8 @@ export function MycelialIntelligenceBar() {
<div style={{ <div style={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '4px', gap: '2px',
padding: '6px 10px 6px 14px', padding: '4px 8px 4px 12px',
height: '100%', height: '100%',
justifyContent: 'center', justifyContent: 'center',
}}> }}>
@ -1431,7 +1519,7 @@ export function MycelialIntelligenceBar() {
flexShrink: 0, flexShrink: 0,
}}> }}>
<span style={{ <span style={{
fontSize: '16px', fontSize: '14px',
opacity: 0.9, opacity: 0.9,
}}> }}>
🍄🧠 🍄🧠
@ -1487,13 +1575,20 @@ export function MycelialIntelligenceBar() {
flex: 1, flex: 1,
background: 'transparent', background: 'transparent',
border: 'none', border: 'none',
padding: '8px 4px', padding: '6px 4px',
fontSize: '14px', fontSize: '13px',
color: colors.inputText, color: colors.inputText,
outline: 'none', outline: 'none',
}} }}
/> />
{/* Connection status indicator - unobtrusive */}
<ConnectionStatusBadge
connectionState={connectionState}
isNetworkOnline={isNetworkOnline}
isDark={isDark}
/>
{/* Indexing indicator */} {/* Indexing indicator */}
{isIndexing && ( {isIndexing && (
<span style={{ <span style={{
@ -1515,8 +1610,8 @@ export function MycelialIntelligenceBar() {
}} }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
style={{ style={{
width: '34px', width: '28px',
height: '34px', height: '28px',
borderRadius: '50%', borderRadius: '50%',
border: 'none', border: 'none',
background: isRecording background: isRecording
@ -1549,9 +1644,9 @@ export function MycelialIntelligenceBar() {
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
disabled={!prompt.trim() || isLoading} disabled={!prompt.trim() || isLoading}
style={{ style={{
height: '34px', height: '28px',
padding: selectedToolInfo ? '0 12px' : '0 14px', padding: selectedToolInfo ? '0 10px' : '0 12px',
borderRadius: '17px', borderRadius: '14px',
border: 'none', border: 'none',
background: prompt.trim() && !isLoading background: prompt.trim() && !isLoading
? selectedToolInfo ? '#6366f1' : ACCENT_COLOR ? selectedToolInfo ? '#6366f1' : ACCENT_COLOR
@ -1589,8 +1684,8 @@ export function MycelialIntelligenceBar() {
}} }}
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
style={{ style={{
width: '34px', width: '28px',
height: '34px', height: '28px',
borderRadius: '50%', borderRadius: '50%',
border: 'none', border: 'none',
background: 'transparent', 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 style={{ fontStyle: 'italic', opacity: 0.85 }}>ask your mycelial intelligence anything about this workspace</span>
</span> </span>
{/* Connection status in expanded header */}
<ConnectionStatusBadge
connectionState={connectionState}
isNetworkOnline={isNetworkOnline}
isDark={isDark}
/>
{isIndexing && ( {isIndexing && (
<span style={{ <span style={{
color: colors.textMuted, color: colors.textMuted,
@ -2087,6 +2188,10 @@ export function MycelialIntelligenceBar() {
0%, 80%, 100% { transform: scale(0); } 0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); } 40% { transform: scale(1); }
} }
@keyframes connectionPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
`}</style> `}</style>
</div> </div>
) )

View File

@ -1,16 +1,20 @@
import React from "react" import React from "react"
import { createPortal } from "react-dom"
import { useParams } from "react-router-dom"
import { CustomMainMenu } from "./CustomMainMenu" import { CustomMainMenu } from "./CustomMainMenu"
import { CustomToolbar } from "./CustomToolbar" import { CustomToolbar } from "./CustomToolbar"
import { CustomContextMenu } from "./CustomContextMenu" import { CustomContextMenu } from "./CustomContextMenu"
import { FocusLockIndicator } from "./FocusLockIndicator" import { FocusLockIndicator } from "./FocusLockIndicator"
import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar" import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar"
import { CommandPalette } from "./CommandPalette" import { CommandPalette } from "./CommandPalette"
import { UserSettingsModal } from "./UserSettingsModal"
import { NetworkGraphPanel } from "../components/networking" import { NetworkGraphPanel } from "../components/networking"
import CryptIDDropdown from "../components/auth/CryptIDDropdown" import CryptIDDropdown from "../components/auth/CryptIDDropdown"
import StarBoardButton from "../components/StarBoardButton" import StarBoardButton from "../components/StarBoardButton"
import ShareBoardButton from "../components/ShareBoardButton" import ShareBoardButton from "../components/ShareBoardButton"
import { SettingsDialog } from "./SettingsDialog" import { SettingsDialog } from "./SettingsDialog"
import { useAuth } from "../context/AuthContext"
import { PermissionLevel } from "../lib/auth/types"
import { WORKER_URL } from "../constants/workerUrl"
import { import {
DefaultKeyboardShortcutsDialog, DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent, DefaultKeyboardShortcutsDialogContent,
@ -32,16 +36,103 @@ const AI_TOOLS = [
{ id: 'mycelial', name: 'Mycelial', icon: '🍄', model: 'llama3.1:70b', provider: 'Ollama', type: 'local' }, { 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 // Custom SharePanel with layout: CryptID -> Star -> Gear -> Question mark
function CustomSharePanel() { function CustomSharePanel() {
const tools = useTools() const tools = useTools()
const actions = useActions() const actions = useActions()
const { addDialog, removeDialog } = useDialogs() const { addDialog, removeDialog } = useDialogs()
const { session } = useAuth()
const { slug } = useParams<{ slug: string }>()
const boardId = slug || 'mycofi33'
const [showShortcuts, setShowShortcuts] = React.useState(false) const [showShortcuts, setShowShortcuts] = React.useState(false)
const [showSettings, setShowSettings] = React.useState(false)
const [showSettingsDropdown, setShowSettingsDropdown] = React.useState(false) const [showSettingsDropdown, setShowSettingsDropdown] = React.useState(false)
const [showAISection, setShowAISection] = React.useState(false) const [showAISection, setShowAISection] = React.useState(false)
const [hasApiKey, setHasApiKey] = 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 // ESC key handler for closing dropdowns
React.useEffect(() => { React.useEffect(() => {
@ -236,8 +327,9 @@ function CustomSharePanel() {
<Separator /> <Separator />
{/* Settings gear button with dropdown */} {/* Settings gear button with dropdown */}
<div style={{ position: 'relative', padding: '0 2px' }}> <div style={{ padding: '0 2px' }}>
<button <button
ref={settingsButtonRef}
onClick={() => setShowSettingsDropdown(!showSettingsDropdown)} onClick={() => setShowSettingsDropdown(!showSettingsDropdown)}
className="share-panel-btn" className="share-panel-btn"
style={{ style={{
@ -272,8 +364,8 @@ function CustomSharePanel() {
</svg> </svg>
</button> </button>
{/* Settings dropdown */} {/* Settings dropdown - rendered via portal to break out of parent container */}
{showSettingsDropdown && ( {showSettingsDropdown && settingsDropdownPos && createPortal(
<> <>
{/* Backdrop - only uses onClick, not onPointerDown */} {/* Backdrop - only uses onClick, not onPointerDown */}
<div <div
@ -288,9 +380,9 @@ function CustomSharePanel() {
{/* Dropdown menu */} {/* Dropdown menu */}
<div <div
style={{ style={{
position: 'absolute', position: 'fixed',
top: 'calc(100% + 8px)', top: settingsDropdownPos.top,
right: 0, right: settingsDropdownPos.right,
minWidth: '200px', minWidth: '200px',
maxHeight: '60vh', maxHeight: '60vh',
overflowY: 'auto', overflowY: 'auto',
@ -306,6 +398,121 @@ function CustomSharePanel() {
onWheel={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}
onClick={(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 */} {/* Dark mode toggle */}
<button <button
onClick={() => { onClick={() => {
@ -444,42 +651,9 @@ function CustomSharePanel() {
)} )}
</div> </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> </div>
</> </>,
document.body
)} )}
</div> </div>
@ -488,6 +662,7 @@ function CustomSharePanel() {
{/* Help/Keyboard shortcuts button - rightmost */} {/* Help/Keyboard shortcuts button - rightmost */}
<div style={{ padding: '0 4px' }}> <div style={{ padding: '0 4px' }}>
<button <button
ref={shortcutsButtonRef}
onClick={() => setShowShortcuts(!showShortcuts)} onClick={() => setShowShortcuts(!showShortcuts)}
className="share-panel-btn" className="share-panel-btn"
style={{ style={{
@ -525,8 +700,8 @@ function CustomSharePanel() {
</div> </div>
</div> </div>
{/* Keyboard shortcuts panel */} {/* Keyboard shortcuts panel - rendered via portal to break out of parent container */}
{showShortcuts && ( {showShortcuts && shortcutsDropdownPos && createPortal(
<> <>
{/* Backdrop - only uses onClick, not onPointerDown */} {/* Backdrop - only uses onClick, not onPointerDown */}
<div <div
@ -541,11 +716,11 @@ function CustomSharePanel() {
{/* Shortcuts menu */} {/* Shortcuts menu */}
<div <div
style={{ style={{
position: 'absolute', position: 'fixed',
top: 'calc(100% + 8px)', top: shortcutsDropdownPos.top,
right: 0, right: shortcutsDropdownPos.right,
width: '320px', width: '320px',
maxHeight: '60vh', maxHeight: '50vh',
overflowY: 'auto', overflowY: 'auto',
overflowX: 'hidden', overflowX: 'hidden',
background: 'var(--color-panel)', background: 'var(--color-panel)',
@ -553,7 +728,7 @@ function CustomSharePanel() {
borderRadius: '8px', borderRadius: '8px',
boxShadow: '0 4px 20px rgba(0,0,0,0.2)', boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
zIndex: 99999, zIndex: 99999,
padding: '12px 0', padding: '10px 0',
pointerEvents: 'auto', pointerEvents: 'auto',
}} }}
onWheel={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}
@ -629,17 +804,10 @@ function CustomSharePanel() {
</div> </div>
))} ))}
</div> </div>
</> </>,
document.body
)} )}
{/* Settings Modal */}
{showSettings && (
<UserSettingsModal
onClose={() => setShowSettings(false)}
isDarkMode={isDarkMode}
onToggleDarkMode={handleToggleDarkMode}
/>
)}
</div> </div>
) )
} }

View File

@ -154,6 +154,7 @@ export async function handleGetPermission(
const db = env.CRYPTID_DB; const db = env.CRYPTID_DB;
if (!db) { if (!db) {
// No database - default to view for anonymous (secure by default) // No database - default to view for anonymous (secure by default)
console.log('🔐 Permission check: No database configured');
return new Response(JSON.stringify({ return new Response(JSON.stringify({
permission: 'view', permission: 'view',
isOwner: false, isOwner: false,
@ -168,6 +169,10 @@ export async function handleGetPermission(
let userId: string | null = null; let userId: string | null = null;
const publicKey = request.headers.get('X-CryptID-PublicKey'); const publicKey = request.headers.get('X-CryptID-PublicKey');
console.log('🔐 Permission check for board:', boardId, {
publicKeyReceived: publicKey ? `${publicKey.substring(0, 20)}...` : null
});
if (publicKey) { if (publicKey) {
const deviceKey = await db.prepare( const deviceKey = await db.prepare(
'SELECT user_id FROM device_keys WHERE public_key = ?' 'SELECT user_id FROM device_keys WHERE public_key = ?'
@ -175,6 +180,9 @@ export async function handleGetPermission(
if (deviceKey) { if (deviceKey) {
userId = deviceKey.user_id; 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 accessToken = url.searchParams.get('token');
const result = await getEffectivePermission(db, boardId, userId, accessToken); const result = await getEffectivePermission(db, boardId, userId, accessToken);
console.log('🔐 Permission result:', result);
return new Response(JSON.stringify(result), { return new Response(JSON.stringify(result), {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -293,6 +302,10 @@ export async function handleListPermissions(
* POST /boards/:boardId/permissions * POST /boards/:boardId/permissions
* Grant permission to a user (admin only) * Grant permission to a user (admin only)
* Body: { userId, permission, username? } * 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( export async function handleGrantPermission(
boardId: string, boardId: string,
@ -709,8 +722,12 @@ export async function handleCreateAccessToken(
maxUses?: number; maxUses?: number;
}; };
if (!body.permission || !['view', 'edit', 'admin'].includes(body.permission)) { // Only allow 'view' and 'edit' permissions for access tokens
return new Response(JSON.stringify({ error: 'Invalid permission level' }), { // 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, status: 400,
headers: { 'Content-Type': 'application/json' }, 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, id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
email_verified INTEGER DEFAULT 0, email_verified INTEGER DEFAULT 0,
cryptid_username TEXT NOT NULL, cryptid_username TEXT UNIQUE NOT NULL,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_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); 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 -- 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 ( CREATE TABLE IF NOT EXISTS board_access_tokens (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
board_id TEXT NOT NULL, board_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE, -- Random hex token (64 chars) 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_by TEXT NOT NULL, -- User ID who created the token
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT, -- NULL = never expires expires_at TEXT, -- NULL = never expires

View File

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

View File

@ -29,6 +29,9 @@ import {
} from "./boardPermissions" } from "./boardPermissions"
import { import {
handleSendBackupEmail, handleSendBackupEmail,
handleSearchUsers,
handleListAllUsers,
handleCheckUsername,
} from "./cryptidAuth" } from "./cryptidAuth"
// make sure our sync durable objects are made available to cloudflare // 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-CryptID-PublicKey", // CryptID authentication header
"X-User-Id", // User ID header for networking API "X-User-Id", // User ID header for networking API
"X-Api-Key", // API key header for external services "X-Api-Key", // API key header for external services
"X-Admin-Secret", // Admin secret header for protected endpoints
"*" "*"
], ],
maxAge: 86400, 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 // CryptID Auth API
// ============================================================================= // =============================================================================
// Check if a username is available for registration
.get("/api/auth/check-username", handleCheckUsername)
// Send backup email for multi-device setup // Send backup email for multi-device setup
.post("/api/auth/send-backup-email", handleSendBackupEmail) .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 // User Networking / Social Graph API
// ============================================================================= // =============================================================================