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:
parent
f277aeec12
commit
4236f040f3
|
|
@ -274,12 +274,7 @@ export function useAutomergeStoreV2({
|
|||
return
|
||||
}
|
||||
|
||||
// Broadcasting changes via JSON sync
|
||||
const shapeRecords = addedOrUpdatedRecords.filter(r => r?.typeName === 'shape')
|
||||
const deletedShapes = deletedRecordIds.filter(id => id.startsWith('shape:'))
|
||||
if (shapeRecords.length > 0 || deletedShapes.length > 0) {
|
||||
console.log(`📤 Broadcasting ${shapeRecords.length} shape changes and ${deletedShapes.length} deletions via JSON sync`)
|
||||
}
|
||||
// Broadcasting changes via JSON sync (logging disabled for performance)
|
||||
|
||||
if (adapter && typeof (adapter as any).send === 'function') {
|
||||
// Send changes to other clients via the network adapter
|
||||
|
|
@ -303,50 +298,23 @@ export function useAutomergeStoreV2({
|
|||
// Listen for changes from Automerge and apply them to TLDraw
|
||||
const automergeChangeHandler = (payload: DocHandleChangePayload<any>) => {
|
||||
const patchCount = payload.patches?.length || 0
|
||||
const shapePatches = payload.patches?.filter((p: any) => {
|
||||
const id = p.path?.[1]
|
||||
return id && typeof id === 'string' && id.startsWith('shape:')
|
||||
}) || []
|
||||
|
||||
// Debug logging for sync issues
|
||||
console.log(`🔄 automergeChangeHandler: ${patchCount} patches (${shapePatches.length} shapes), pendingLocalChanges=${pendingLocalChanges}`)
|
||||
|
||||
// Skip echoes of our own local changes using a counter.
|
||||
// Each local handle.change() increments the counter, and each echo decrements it.
|
||||
// Only process changes when counter is 0 (those are remote changes from other clients).
|
||||
if (pendingLocalChanges > 0) {
|
||||
console.log(`⏭️ Skipping echo (pendingLocalChanges was ${pendingLocalChanges}, now ${pendingLocalChanges - 1})`)
|
||||
pendingLocalChanges--
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`✅ Processing ${patchCount} patches as REMOTE changes (${shapePatches.length} shape patches)`)
|
||||
|
||||
try {
|
||||
// Apply patches from Automerge to TLDraw store
|
||||
if (payload.patches && payload.patches.length > 0) {
|
||||
// Debug: Check if patches contain shapes
|
||||
if (shapePatches.length > 0) {
|
||||
console.log(`📥 Applying ${shapePatches.length} shape patches from remote`)
|
||||
}
|
||||
|
||||
try {
|
||||
const recordsBefore = store.allRecords()
|
||||
const shapesBefore = recordsBefore.filter((r: any) => r.typeName === 'shape')
|
||||
|
||||
// CRITICAL: Pass Automerge document to patch handler so it can read full records
|
||||
// This prevents coordinates from defaulting to 0,0 when patches create new records
|
||||
const automergeDoc = handle.doc()
|
||||
applyAutomergePatchesToTLStore(payload.patches, store, automergeDoc)
|
||||
|
||||
const recordsAfter = store.allRecords()
|
||||
const shapesAfter = recordsAfter.filter((r: any) => r.typeName === 'shape')
|
||||
|
||||
if (shapesAfter.length !== shapesBefore.length) {
|
||||
// Patches applied
|
||||
}
|
||||
|
||||
// Patches processed successfully
|
||||
} catch (patchError) {
|
||||
console.error("Error applying patches batch, attempting individual patch application:", patchError)
|
||||
// Try applying patches one by one to identify problematic ones
|
||||
|
|
@ -580,78 +548,91 @@ export function useAutomergeStoreV2({
|
|||
// Track recent eraser activity to detect active eraser drags
|
||||
let lastEraserActivity = 0
|
||||
let eraserToolSelected = false
|
||||
let lastEraserCheckTime = 0
|
||||
let cachedEraserActive = false
|
||||
const ERASER_ACTIVITY_THRESHOLD = 2000 // Increased to 2 seconds to handle longer eraser drags
|
||||
const ERASER_CHECK_CACHE_MS = 100 // Only refresh eraser state every 100ms to avoid expensive checks
|
||||
let eraserChangeQueue: RecordsDiff<TLRecord> | null = null
|
||||
let eraserCheckInterval: NodeJS.Timeout | null = null
|
||||
|
||||
|
||||
// Helper to check if eraser tool is actively erasing (to prevent saves during eraser drag)
|
||||
// OPTIMIZED: Uses cached state and only refreshes periodically to avoid expensive store.allRecords() calls
|
||||
const isEraserActive = (): boolean => {
|
||||
const now = Date.now()
|
||||
|
||||
// Use cached result if checked recently
|
||||
if (now - lastEraserCheckTime < ERASER_CHECK_CACHE_MS) {
|
||||
return cachedEraserActive
|
||||
}
|
||||
lastEraserCheckTime = now
|
||||
|
||||
// If eraser was selected and recent activity, assume still active
|
||||
if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
|
||||
cachedEraserActive = true
|
||||
return true
|
||||
}
|
||||
|
||||
// If no recent eraser activity and not marked as selected, quickly return false
|
||||
if (!eraserToolSelected && now - lastEraserActivity > ERASER_ACTIVITY_THRESHOLD) {
|
||||
cachedEraserActive = false
|
||||
return false
|
||||
}
|
||||
|
||||
// Only do expensive check if eraser might be transitioning
|
||||
try {
|
||||
const allRecords = store.allRecords()
|
||||
|
||||
// Use store.get() for specific records instead of allRecords() for better performance
|
||||
const instancePageState = store.get('instance_page_state:page:page' as any)
|
||||
|
||||
// Check instance_page_state for erasingShapeIds (most reliable indicator)
|
||||
const instancePageState = allRecords.find((r: any) =>
|
||||
r.typeName === 'instance_page_state' &&
|
||||
(r as any).erasingShapeIds &&
|
||||
Array.isArray((r as any).erasingShapeIds) &&
|
||||
(r as any).erasingShapeIds.length > 0
|
||||
)
|
||||
|
||||
if (instancePageState) {
|
||||
lastEraserActivity = Date.now()
|
||||
if (instancePageState &&
|
||||
(instancePageState as any).erasingShapeIds &&
|
||||
Array.isArray((instancePageState as any).erasingShapeIds) &&
|
||||
(instancePageState as any).erasingShapeIds.length > 0) {
|
||||
lastEraserActivity = now
|
||||
eraserToolSelected = true
|
||||
cachedEraserActive = true
|
||||
return true // Eraser is actively erasing shapes
|
||||
}
|
||||
|
||||
|
||||
// Check if eraser tool is selected
|
||||
const instance = allRecords.find((r: any) => r.typeName === 'instance')
|
||||
const instance = store.get('instance:instance' as any)
|
||||
const currentToolId = instance ? (instance as any).currentToolId : null
|
||||
|
||||
|
||||
if (currentToolId === 'eraser') {
|
||||
eraserToolSelected = true
|
||||
const now = Date.now()
|
||||
// If eraser tool is selected, keep it active for longer to handle drags
|
||||
// Also check if there was recent activity
|
||||
if (now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
|
||||
return true
|
||||
}
|
||||
// If tool is selected but no recent activity, still consider it active
|
||||
// (user might be mid-drag)
|
||||
lastEraserActivity = now
|
||||
cachedEraserActive = true
|
||||
return true
|
||||
} else {
|
||||
// Tool switched away - only consider active if very recent activity
|
||||
eraserToolSelected = false
|
||||
const now = Date.now()
|
||||
if (now - lastEraserActivity < 300) {
|
||||
return true // Very recent activity, might still be processing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
cachedEraserActive = false
|
||||
return false
|
||||
} catch (e) {
|
||||
// If we can't check, use last known state with timeout
|
||||
const now = Date.now()
|
||||
if (eraserToolSelected && now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
|
||||
cachedEraserActive = true
|
||||
return true
|
||||
}
|
||||
cachedEraserActive = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Track eraser activity from shape deletions
|
||||
// OPTIMIZED: Only check for eraser tool when shapes are removed, and use cached tool state
|
||||
const checkForEraserActivity = (changes: RecordsDiff<TLRecord>) => {
|
||||
// If shapes are being removed and eraser tool might be active, mark activity
|
||||
if (changes.removed) {
|
||||
const removedShapes = Object.values(changes.removed).filter((r: any) =>
|
||||
r && r.typeName === 'shape'
|
||||
)
|
||||
if (removedShapes.length > 0) {
|
||||
// Check if eraser tool is currently selected
|
||||
const allRecords = store.allRecords()
|
||||
const instance = allRecords.find((r: any) => r.typeName === 'instance')
|
||||
if (instance && (instance as any).currentToolId === 'eraser') {
|
||||
lastEraserActivity = Date.now()
|
||||
eraserToolSelected = true
|
||||
const removedKeys = Object.keys(changes.removed)
|
||||
// Quick check: if no shape keys, skip
|
||||
const hasRemovedShapes = removedKeys.some(key => key.startsWith('shape:'))
|
||||
if (hasRemovedShapes) {
|
||||
// Use cached eraserToolSelected state if recent, avoid expensive allRecords() call
|
||||
const now = Date.now()
|
||||
if (eraserToolSelected || now - lastEraserActivity < ERASER_ACTIVITY_THRESHOLD) {
|
||||
lastEraserActivity = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -688,17 +669,6 @@ export function useAutomergeStoreV2({
|
|||
id.startsWith('pointer:')
|
||||
)
|
||||
|
||||
// DEBUG: Log why records are being filtered or not
|
||||
const shouldFilter = (typeName && ephemeralTypes.includes(typeName)) || idMatchesEphemeral
|
||||
if (shouldFilter) {
|
||||
console.log(`🚫 Filtering out ephemeral record:`, {
|
||||
id,
|
||||
typeName,
|
||||
idMatchesEphemeral,
|
||||
typeNameMatches: typeName && ephemeralTypes.includes(typeName)
|
||||
})
|
||||
}
|
||||
|
||||
// Filter out if typeName matches OR if ID pattern matches ephemeral types
|
||||
if (typeName && ephemeralTypes.includes(typeName)) {
|
||||
// Skip - this is an ephemeral record
|
||||
|
|
@ -721,183 +691,9 @@ export function useAutomergeStoreV2({
|
|||
removed: filterEphemeral(changes.removed),
|
||||
}
|
||||
|
||||
// DEBUG: Log all changes to see what's being detected
|
||||
const totalChanges = Object.keys(changes.added || {}).length + Object.keys(changes.updated || {}).length + Object.keys(changes.removed || {}).length
|
||||
// Calculate change counts (minimal, needed for early return)
|
||||
const filteredTotalChanges = Object.keys(filteredChanges.added || {}).length + Object.keys(filteredChanges.updated || {}).length + Object.keys(filteredChanges.removed || {}).length
|
||||
|
||||
// DEBUG: Log ALL changes (before filtering) to see what's actually being updated
|
||||
if (totalChanges > 0) {
|
||||
const allChangedRecords: Array<{id: string, typeName: string, changeType: string}> = []
|
||||
if (changes.added) {
|
||||
Object.entries(changes.added).forEach(([id, record]: [string, any]) => {
|
||||
const recordObj = Array.isArray(record) ? record[1] : record
|
||||
allChangedRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'added' })
|
||||
})
|
||||
}
|
||||
if (changes.updated) {
|
||||
Object.entries(changes.updated).forEach(([id, [_, record]]: [string, [any, any]]) => {
|
||||
allChangedRecords.push({ id, typeName: record?.typeName || 'unknown', changeType: 'updated' })
|
||||
})
|
||||
}
|
||||
if (changes.removed) {
|
||||
Object.entries(changes.removed).forEach(([id, record]: [string, any]) => {
|
||||
const recordObj = Array.isArray(record) ? record[1] : record
|
||||
allChangedRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'removed' })
|
||||
})
|
||||
}
|
||||
console.log(`🔍 ALL changes detected (before filtering):`, {
|
||||
total: totalChanges,
|
||||
records: allChangedRecords,
|
||||
// Also log the actual record objects to see their structure
|
||||
recordDetails: allChangedRecords.map(r => {
|
||||
let record: any = null
|
||||
if (r.changeType === 'added' && changes.added) {
|
||||
const rec = (changes.added as any)[r.id]
|
||||
record = Array.isArray(rec) ? rec[1] : rec
|
||||
} else if (r.changeType === 'updated' && changes.updated) {
|
||||
const rec = (changes.updated as any)[r.id]
|
||||
record = Array.isArray(rec) ? rec[1] : rec
|
||||
} else if (r.changeType === 'removed' && changes.removed) {
|
||||
const rec = (changes.removed as any)[r.id]
|
||||
record = Array.isArray(rec) ? rec[1] : rec
|
||||
}
|
||||
return {
|
||||
id: r.id,
|
||||
typeName: r.typeName,
|
||||
changeType: r.changeType,
|
||||
hasTypeName: !!record?.typeName,
|
||||
actualTypeName: record?.typeName,
|
||||
recordKeys: record ? Object.keys(record).slice(0, 10) : []
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Log if we filtered out any ephemeral changes
|
||||
if (totalChanges > 0 && filteredTotalChanges < totalChanges) {
|
||||
const filteredCount = totalChanges - filteredTotalChanges
|
||||
const filteredTypes = new Set<string>()
|
||||
const filteredIds: string[] = []
|
||||
if (changes.added) {
|
||||
Object.entries(changes.added).forEach(([id, record]: [string, any]) => {
|
||||
const recordObj = Array.isArray(record) ? record[1] : record
|
||||
if (recordObj && ephemeralTypes.includes(recordObj.typeName)) {
|
||||
filteredTypes.add(recordObj.typeName)
|
||||
filteredIds.push(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (changes.updated) {
|
||||
Object.entries(changes.updated).forEach(([id, [_, record]]: [string, [any, any]]) => {
|
||||
if (ephemeralTypes.includes(record.typeName)) {
|
||||
filteredTypes.add(record.typeName)
|
||||
filteredIds.push(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (changes.removed) {
|
||||
Object.entries(changes.removed).forEach(([id, record]: [string, any]) => {
|
||||
const recordObj = Array.isArray(record) ? record[1] : record
|
||||
if (recordObj && ephemeralTypes.includes(recordObj.typeName)) {
|
||||
filteredTypes.add(recordObj.typeName)
|
||||
filteredIds.push(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
console.log(`🚫 Filtered out ${filteredCount} ephemeral change(s) (${Array.from(filteredTypes).join(', ')}) - not persisting`, {
|
||||
filteredIds: filteredIds.slice(0, 5), // Show first 5 IDs
|
||||
totalFiltered: filteredIds.length
|
||||
})
|
||||
}
|
||||
|
||||
if (filteredTotalChanges > 0) {
|
||||
// Log what records are passing through the filter (shouldn't happen for ephemeral records)
|
||||
const passingRecords: Array<{id: string, typeName: string, changeType: string}> = []
|
||||
if (filteredChanges.added) {
|
||||
Object.entries(filteredChanges.added).forEach(([id, record]: [string, any]) => {
|
||||
const recordObj = Array.isArray(record) ? record[1] : record
|
||||
passingRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'added' })
|
||||
})
|
||||
}
|
||||
if (filteredChanges.updated) {
|
||||
Object.entries(filteredChanges.updated).forEach(([id, recordTuple]: [string, any]) => {
|
||||
const record = Array.isArray(recordTuple) && recordTuple.length === 2 ? recordTuple[1] : recordTuple
|
||||
passingRecords.push({ id, typeName: (record as any)?.typeName || 'unknown', changeType: 'updated' })
|
||||
})
|
||||
}
|
||||
if (filteredChanges.removed) {
|
||||
Object.entries(filteredChanges.removed).forEach(([id, record]: [string, any]) => {
|
||||
const recordObj = Array.isArray(record) ? record[1] : record
|
||||
passingRecords.push({ id, typeName: recordObj?.typeName || 'unknown', changeType: 'removed' })
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`🔍 TLDraw store changes detected (source: ${source}):`, {
|
||||
added: Object.keys(filteredChanges.added || {}).length,
|
||||
updated: Object.keys(filteredChanges.updated || {}).length,
|
||||
removed: Object.keys(filteredChanges.removed || {}).length,
|
||||
source: source,
|
||||
passingRecords: passingRecords // Show what's actually passing through
|
||||
})
|
||||
|
||||
// DEBUG: Check for richText/text changes in updated records
|
||||
if (filteredChanges.updated) {
|
||||
Object.values(filteredChanges.updated).forEach((recordTuple: any) => {
|
||||
const record = Array.isArray(recordTuple) && recordTuple.length === 2 ? recordTuple[1] : recordTuple
|
||||
if ((record as any)?.typeName === 'shape') {
|
||||
const rec = record as any
|
||||
if (rec.type === 'geo' && rec.props?.richText) {
|
||||
console.log(`🔍 Geo shape ${rec.id} richText change detected:`, {
|
||||
hasRichText: !!rec.props.richText,
|
||||
richTextType: typeof rec.props.richText,
|
||||
source: source
|
||||
})
|
||||
}
|
||||
if (rec.type === 'note' && rec.props?.richText) {
|
||||
console.log(`🔍 Note shape ${rec.id} richText change detected:`, {
|
||||
hasRichText: !!rec.props.richText,
|
||||
richTextType: typeof rec.props.richText,
|
||||
richTextContentLength: Array.isArray(rec.props.richText?.content)
|
||||
? rec.props.richText.content.length
|
||||
: 'not array',
|
||||
source: source
|
||||
})
|
||||
}
|
||||
if (rec.type === 'arrow' && rec.props?.text !== undefined) {
|
||||
console.log(`🔍 Arrow shape ${rec.id} text change detected:`, {
|
||||
hasText: !!rec.props.text,
|
||||
textValue: rec.props.text,
|
||||
source: source
|
||||
})
|
||||
}
|
||||
if (rec.type === 'text' && rec.props?.richText) {
|
||||
console.log(`🔍 Text shape ${rec.id} richText change detected:`, {
|
||||
hasRichText: !!rec.props.richText,
|
||||
richTextType: typeof rec.props.richText,
|
||||
source: source
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// DEBUG: Log added shapes to track what's being created
|
||||
if (filteredChanges.added) {
|
||||
Object.values(filteredChanges.added).forEach((record: any) => {
|
||||
const rec = Array.isArray(record) ? record[1] : record
|
||||
if (rec?.typeName === 'shape') {
|
||||
console.log(`🔍 Shape added: ${rec.type} (${rec.id})`, {
|
||||
type: rec.type,
|
||||
id: rec.id,
|
||||
hasRichText: !!rec.props?.richText,
|
||||
hasText: !!rec.props?.text,
|
||||
source: source
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Skip if no meaningful changes after filtering ephemeral records
|
||||
if (filteredTotalChanges === 0) {
|
||||
return
|
||||
|
|
@ -906,7 +702,6 @@ export function useAutomergeStoreV2({
|
|||
// CRITICAL: Skip broadcasting changes that came from remote sources to prevent feedback loops
|
||||
// Only broadcast changes that originated from user interactions (source === 'user')
|
||||
if (source === 'remote') {
|
||||
console.log('🔄 Skipping broadcast for remote change to prevent feedback loop')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1044,38 +839,6 @@ export function useAutomergeStoreV2({
|
|||
// Check if this is a position-only update that should be throttled
|
||||
const isPositionOnly = isPositionOnlyUpdate(finalFilteredChanges)
|
||||
|
||||
// Log what type of change this is for debugging
|
||||
const changeType = Object.keys(finalFilteredChanges.added || {}).length > 0 ? 'added' :
|
||||
Object.keys(finalFilteredChanges.removed || {}).length > 0 ? 'removed' :
|
||||
isPositionOnly ? 'position-only' : 'property-change'
|
||||
|
||||
// DEBUG: Log dimension changes for shapes
|
||||
if (finalFilteredChanges.updated) {
|
||||
Object.entries(finalFilteredChanges.updated).forEach(([id, recordTuple]: [string, any]) => {
|
||||
const isTuple = Array.isArray(recordTuple) && recordTuple.length === 2
|
||||
const oldRecord = isTuple ? recordTuple[0] : null
|
||||
const newRecord = isTuple ? recordTuple[1] : recordTuple
|
||||
if (newRecord?.typeName === 'shape') {
|
||||
const oldProps = oldRecord?.props || {}
|
||||
const newProps = newRecord?.props || {}
|
||||
if (oldProps.w !== newProps.w || oldProps.h !== newProps.h) {
|
||||
console.log(`🔍 Shape dimension change detected for ${newRecord.type} ${id}:`, {
|
||||
oldDims: { w: oldProps.w, h: oldProps.h },
|
||||
newDims: { w: newProps.w, h: newProps.h },
|
||||
source
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`🔍 Change detected: ${changeType}, will ${isPositionOnly ? 'throttle' : 'broadcast immediately'}`, {
|
||||
added: Object.keys(finalFilteredChanges.added || {}).length,
|
||||
updated: Object.keys(finalFilteredChanges.updated || {}).length,
|
||||
removed: Object.keys(finalFilteredChanges.removed || {}).length,
|
||||
source
|
||||
})
|
||||
|
||||
if (isPositionOnly && positionUpdateQueue === null) {
|
||||
// Start a new queue for position updates
|
||||
positionUpdateQueue = finalFilteredChanges
|
||||
|
|
@ -1258,12 +1021,7 @@ export function useAutomergeStoreV2({
|
|||
broadcastJsonSync(addedOrUpdatedRecords, deletedRecordIds)
|
||||
}
|
||||
|
||||
// Only log if there are many changes or if debugging is needed
|
||||
if (filteredTotalChanges > 3) {
|
||||
console.log(`✅ Applied ${filteredTotalChanges} TLDraw changes to Automerge document`)
|
||||
} else if (filteredTotalChanges > 0) {
|
||||
console.log(`✅ Applied ${filteredTotalChanges} TLDraw change(s) to Automerge document`)
|
||||
}
|
||||
// Logging disabled for performance during continuous drawing
|
||||
|
||||
// Check if the document actually changed
|
||||
const docAfter = handle.doc()
|
||||
|
|
|
|||
|
|
@ -203,6 +203,31 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
console.log(`✅ Applied ${changedRecordCount} records and ${deletedRecordIds.length} deletions to Automerge document`)
|
||||
}, [])
|
||||
|
||||
// Presence update batching to prevent "Maximum update depth exceeded" errors
|
||||
// We batch presence updates and apply them in a single mergeRemoteChanges call
|
||||
const pendingPresenceUpdates = useRef<Map<string, any>>(new Map())
|
||||
const presenceUpdateTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
const PRESENCE_BATCH_INTERVAL_MS = 16 // ~60fps, batch updates every frame
|
||||
|
||||
// Flush pending presence updates to the store
|
||||
const flushPresenceUpdates = useCallback(() => {
|
||||
const currentStore = storeRef.current
|
||||
if (!currentStore || pendingPresenceUpdates.current.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const updates = Array.from(pendingPresenceUpdates.current.values())
|
||||
pendingPresenceUpdates.current.clear()
|
||||
|
||||
try {
|
||||
currentStore.mergeRemoteChanges(() => {
|
||||
currentStore.put(updates)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ Error flushing presence updates:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Presence update callback - applies presence from other clients
|
||||
// Presence is ephemeral (cursors, selections) and goes directly to the store
|
||||
// Note: This callback is passed to the adapter but accesses storeRef which updates later
|
||||
|
|
@ -256,16 +281,21 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
lastActivityTimestamp: Date.now()
|
||||
})
|
||||
|
||||
// Apply the instance_presence record using mergeRemoteChanges for atomic updates
|
||||
currentStore.mergeRemoteChanges(() => {
|
||||
currentStore.put([instancePresence])
|
||||
})
|
||||
// Queue the presence update for batched application
|
||||
pendingPresenceUpdates.current.set(presenceId, instancePresence)
|
||||
|
||||
// Schedule a flush if not already scheduled
|
||||
if (!presenceUpdateTimer.current) {
|
||||
presenceUpdateTimer.current = setTimeout(() => {
|
||||
presenceUpdateTimer.current = null
|
||||
flushPresenceUpdates()
|
||||
}, PRESENCE_BATCH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
// Presence applied for remote user
|
||||
} catch (error) {
|
||||
console.error('❌ Error applying presence:', error)
|
||||
}
|
||||
}, [])
|
||||
}, [flushPresenceUpdates])
|
||||
|
||||
const { repo, adapter, storageAdapter } = useMemo(() => {
|
||||
const adapter = new CloudflareNetworkAdapter(
|
||||
|
|
@ -541,6 +571,11 @@ export function useAutomergeSync(config: AutomergeSyncConfig): TLStoreWithStatus
|
|||
|
||||
return () => {
|
||||
mounted = false
|
||||
// Clear any pending presence update timer
|
||||
if (presenceUpdateTimer.current) {
|
||||
clearTimeout(presenceUpdateTimer.current)
|
||||
presenceUpdateTimer.current = null
|
||||
}
|
||||
// Disconnect adapter on unmount to clean up WebSocket connection
|
||||
if (adapter) {
|
||||
adapter.disconnect?.()
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
×
|
||||
</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
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,122 +1,479 @@
|
|||
import React from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useDialogs } from 'tldraw';
|
||||
import { InviteDialog } from '../ui/InviteDialog';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
interface ShareBoardButtonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type PermissionType = 'view' | 'edit' | 'admin';
|
||||
|
||||
const PERMISSION_LABELS: Record<PermissionType, { label: string; description: string; color: string }> = {
|
||||
view: { label: 'View', description: 'Can view but not edit', color: '#6b7280' },
|
||||
edit: { label: 'Edit', description: 'Can view and edit', color: '#3b82f6' },
|
||||
admin: { label: 'Admin', description: 'Full control', color: '#10b981' },
|
||||
};
|
||||
|
||||
const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) => {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { addDialog, removeDialog } = useDialogs();
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [permission, setPermission] = useState<PermissionType>('edit');
|
||||
const [nfcStatus, setNfcStatus] = useState<'idle' | 'writing' | 'success' | 'error' | 'unsupported'>('idle');
|
||||
const [nfcMessage, setNfcMessage] = useState('');
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
|
||||
|
||||
const handleShare = () => {
|
||||
const boardSlug = slug || 'mycofi33';
|
||||
const boardUrl = `${window.location.origin}/board/${boardSlug}`;
|
||||
const boardSlug = slug || 'mycofi33';
|
||||
const boardUrl = `${window.location.origin}/board/${boardSlug}`;
|
||||
|
||||
addDialog({
|
||||
id: "invite-dialog",
|
||||
component: ({ onClose }: { onClose: () => void }) => (
|
||||
<InviteDialog
|
||||
onClose={() => {
|
||||
onClose();
|
||||
removeDialog("invite-dialog");
|
||||
}}
|
||||
boardUrl={boardUrl}
|
||||
boardSlug={boardSlug}
|
||||
/>
|
||||
),
|
||||
});
|
||||
// Update dropdown position when it opens
|
||||
useEffect(() => {
|
||||
if (showDropdown && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + 8,
|
||||
right: window.innerWidth - rect.right,
|
||||
});
|
||||
}
|
||||
}, [showDropdown]);
|
||||
|
||||
// Generate URL with permission parameter
|
||||
const getShareUrl = () => {
|
||||
const url = new URL(boardUrl);
|
||||
url.searchParams.set('access', permission);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
// Check NFC support on mount
|
||||
useEffect(() => {
|
||||
if (!('NDEFReader' in window)) {
|
||||
setNfcStatus('unsupported');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Close dropdown when clicking outside or pressing ESC
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
if (showDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [showDropdown]);
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(getShareUrl());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy URL:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNfcWrite = async () => {
|
||||
if (!('NDEFReader' in window)) {
|
||||
setNfcStatus('unsupported');
|
||||
setNfcMessage('NFC is not supported on this device');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setNfcStatus('writing');
|
||||
setNfcMessage('Hold your NFC tag near the device...');
|
||||
|
||||
const ndef = new (window as any).NDEFReader();
|
||||
await ndef.write({
|
||||
records: [
|
||||
{ recordType: "url", data: getShareUrl() }
|
||||
]
|
||||
});
|
||||
|
||||
setNfcStatus('success');
|
||||
setNfcMessage('Board URL written to NFC tag!');
|
||||
setTimeout(() => {
|
||||
setNfcStatus('idle');
|
||||
setNfcMessage('');
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
console.error('NFC write error:', err);
|
||||
setNfcStatus('error');
|
||||
if (err.name === 'NotAllowedError') {
|
||||
setNfcMessage('NFC permission denied. Please allow NFC access.');
|
||||
} else if (err.name === 'NotSupportedError') {
|
||||
setNfcMessage('NFC is not supported on this device');
|
||||
} else {
|
||||
setNfcMessage(`Failed to write NFC tag: ${err.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Detect if we're in share-panel (compact) vs toolbar (full button)
|
||||
const isCompact = className.includes('share-panel-btn');
|
||||
|
||||
if (isCompact) {
|
||||
// Icon-only version for the top-right share panel
|
||||
// Icon-only version for the top-right share panel with dropdown
|
||||
return (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className={`share-board-button ${className}`}
|
||||
title="Invite others to this board"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '6px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--color-text-1)',
|
||||
opacity: 0.7,
|
||||
transition: 'opacity 0.15s, background 0.15s',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = '0.7';
|
||||
e.currentTarget.style.background = 'none';
|
||||
}}
|
||||
>
|
||||
{/* User with plus icon (invite/add person) */}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* User outline */}
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
{/* Plus sign */}
|
||||
<line x1="19" y1="8" x2="19" y2="14" />
|
||||
<line x1="16" y1="11" x2="22" y2="11" />
|
||||
</svg>
|
||||
</button>
|
||||
<div ref={dropdownRef} style={{ pointerEvents: 'all' }}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className={`share-board-button ${className}`}
|
||||
title="Invite others to this board"
|
||||
style={{
|
||||
background: showDropdown ? 'var(--color-muted-2)' : 'none',
|
||||
border: 'none',
|
||||
padding: '6px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--color-text-1)',
|
||||
opacity: showDropdown ? 1 : 0.7,
|
||||
transition: 'opacity 0.15s, background 0.15s',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!showDropdown) {
|
||||
e.currentTarget.style.opacity = '0.7';
|
||||
e.currentTarget.style.background = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* User with plus icon (invite/add person) */}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* User outline */}
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
{/* Plus sign */}
|
||||
<line x1="19" y1="8" x2="19" y2="14" />
|
||||
<line x1="16" y1="11" x2="22" y2="11" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown - rendered via portal to break out of parent container */}
|
||||
{showDropdown && dropdownPosition && createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: dropdownPosition.top,
|
||||
right: dropdownPosition.right,
|
||||
width: '320px',
|
||||
background: 'var(--color-panel)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
zIndex: 100000,
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid var(--color-panel-contrast)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Invite to Board
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowDropdown(false)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px',
|
||||
color: 'var(--color-text-3)',
|
||||
fontSize: '18px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{/* Board name */}
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '6px 10px',
|
||||
backgroundColor: 'var(--color-muted-2)',
|
||||
borderRadius: '6px',
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>Board: </span>
|
||||
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>{boardSlug}</span>
|
||||
</div>
|
||||
|
||||
{/* Permission selector */}
|
||||
<div>
|
||||
<span style={{ fontSize: '11px', color: 'var(--color-text-3)', fontWeight: 500, marginBottom: '6px', display: 'block' }}>Access Level</span>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{(['view', 'edit', 'admin'] as PermissionType[]).map((perm) => {
|
||||
const isActive = permission === perm;
|
||||
const { label, color } = PERMISSION_LABELS[perm];
|
||||
return (
|
||||
<button
|
||||
key={perm}
|
||||
onClick={() => setPermission(perm)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 6px',
|
||||
border: isActive ? `2px solid ${color}` : '2px solid var(--color-panel-contrast)',
|
||||
background: isActive ? `${color}15` : 'var(--color-panel)',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
color: isActive ? color : 'var(--color-text)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Code and URL */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '12px',
|
||||
backgroundColor: 'var(--color-muted-2)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
{/* QR Code */}
|
||||
<div style={{
|
||||
padding: '8px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<QRCodeSVG
|
||||
value={getShareUrl()}
|
||||
size={80}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL and Copy */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
padding: '8px',
|
||||
backgroundColor: 'var(--color-panel)',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
wordBreak: 'break-all',
|
||||
fontSize: '10px',
|
||||
fontFamily: 'monospace',
|
||||
color: 'var(--color-text)',
|
||||
maxHeight: '40px',
|
||||
overflowY: 'auto',
|
||||
}}>
|
||||
{getShareUrl()}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: copied ? '#10b981' : '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '4px',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy Link'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced options (collapsible) */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
color: 'var(--color-text-3)',
|
||||
padding: '4px 0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
style={{ transform: showAdvanced ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
|
||||
>
|
||||
<path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
More options (NFC, Audio)
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div style={{ marginTop: '8px', display: 'flex', gap: '8px' }}>
|
||||
{/* NFC Button */}
|
||||
<button
|
||||
onClick={handleNfcWrite}
|
||||
disabled={nfcStatus === 'unsupported' || nfcStatus === 'writing'}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
backgroundColor: nfcStatus === 'unsupported' ? 'var(--color-muted-2)' :
|
||||
nfcStatus === 'success' ? '#d1fae5' :
|
||||
nfcStatus === 'error' ? '#fee2e2' :
|
||||
nfcStatus === 'writing' ? '#e0e7ff' : 'var(--color-panel)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '6px',
|
||||
cursor: nfcStatus === 'unsupported' || nfcStatus === 'writing' ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
opacity: nfcStatus === 'unsupported' ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
{nfcStatus === 'success' ? '✓' : nfcStatus === 'error' ? '!' : '📡'}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', color: 'var(--color-text)', fontWeight: 500 }}>
|
||||
{nfcStatus === 'writing' ? 'Writing...' :
|
||||
nfcStatus === 'success' ? 'Written!' :
|
||||
nfcStatus === 'unsupported' ? 'NFC N/A' :
|
||||
'NFC Tag'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Audio Button (coming soon) */}
|
||||
<button
|
||||
disabled
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px',
|
||||
backgroundColor: 'var(--color-muted-2)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '6px',
|
||||
cursor: 'not-allowed',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>🔊</span>
|
||||
<span style={{ fontSize: '10px', color: 'var(--color-text)', fontWeight: 500 }}>
|
||||
Audio (Soon)
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{nfcMessage && (
|
||||
<p style={{
|
||||
marginTop: '6px',
|
||||
fontSize: '10px',
|
||||
color: nfcStatus === 'error' ? '#ef4444' :
|
||||
nfcStatus === 'success' ? '#10b981' : 'var(--color-text-3)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{nfcMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full button version for other contexts (toolbar, etc.)
|
||||
return (
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className={`share-board-button ${className}`}
|
||||
title="Invite others to this board"
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
background: "#3b82f6",
|
||||
color: "white",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
transition: "background 0.2s ease",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
whiteSpace: "nowrap",
|
||||
userSelect: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
height: "22px",
|
||||
minHeight: "22px",
|
||||
boxSizing: "border-box",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "#2563eb";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "#3b82f6";
|
||||
}}
|
||||
>
|
||||
{/* User with plus icon (invite/add person) */}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<line x1="19" y1="8" x2="19" y2="14" />
|
||||
<line x1="16" y1="11" x2="22" y2="11" />
|
||||
</svg>
|
||||
</button>
|
||||
<div ref={dropdownRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className={`share-board-button ${className}`}
|
||||
title="Invite others to this board"
|
||||
style={{
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
background: "#3b82f6",
|
||||
color: "white",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontWeight: 500,
|
||||
transition: "background 0.2s ease",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
||||
whiteSpace: "nowrap",
|
||||
userSelect: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
height: "22px",
|
||||
minHeight: "22px",
|
||||
boxSizing: "border-box",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "#2563eb";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "#3b82f6";
|
||||
}}
|
||||
>
|
||||
{/* User with plus icon (invite/add person) */}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<line x1="19" y1="8" x2="19" y2="14" />
|
||||
<line x1="16" y1="11" x2="22" y2="11" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const StarBoardButton: React.FC<StarBoardButtonProps> = ({ className = '' }) =>
|
|||
|
||||
const handleStarToggle = async () => {
|
||||
if (!session.authed || !session.username || !slug) {
|
||||
addNotification('Please log in to star boards', 'warning');
|
||||
showPopupMessage('Please log in to star boards', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import CryptID from './CryptID';
|
||||
import '../../css/anonymous-banner.css';
|
||||
|
||||
|
|
@ -13,28 +13,27 @@ interface AnonymousViewerBannerProps {
|
|||
/**
|
||||
* Banner shown to anonymous (unauthenticated) users viewing a board.
|
||||
* Explains CryptID and provides a smooth sign-up flow.
|
||||
*
|
||||
* Note: This component should only be rendered when user is NOT authenticated.
|
||||
* The parent component (Board.tsx) handles the auth check via:
|
||||
* {(!session.authed || showEditPrompt) && <AnonymousViewerBanner ... />}
|
||||
*/
|
||||
const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
|
||||
onAuthenticated,
|
||||
triggeredByEdit = false
|
||||
}) => {
|
||||
const { session } = useAuth();
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
const [showSignUp, setShowSignUp] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(triggeredByEdit);
|
||||
|
||||
// Check if banner was previously dismissed this session
|
||||
useEffect(() => {
|
||||
const dismissed = sessionStorage.getItem('anonymousBannerDismissed');
|
||||
if (dismissed && !triggeredByEdit) {
|
||||
setIsDismissed(true);
|
||||
}
|
||||
}, [triggeredByEdit]);
|
||||
|
||||
// If user is authenticated, don't show banner
|
||||
if (session.authed) {
|
||||
return null;
|
||||
}
|
||||
// Note: We intentionally do NOT persist banner dismissal across page loads.
|
||||
// The banner should appear on each new page load for anonymous users
|
||||
// to remind them about CryptID. Only dismiss within the current component lifecycle.
|
||||
//
|
||||
// Previous implementation used sessionStorage to remember dismissal, but this caused
|
||||
// issues where users who dismissed once would never see it again until they closed
|
||||
// their browser entirely - even if they logged out or their session expired.
|
||||
//
|
||||
// If triggeredByEdit is true, always show regardless of dismiss state.
|
||||
|
||||
// If dismissed and not triggered by edit, don't show
|
||||
if (isDismissed && !triggeredByEdit) {
|
||||
|
|
@ -42,7 +41,8 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
|
|||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
sessionStorage.setItem('anonymousBannerDismissed', 'true');
|
||||
// Just set local state - don't persist to sessionStorage
|
||||
// This allows the banner to show again on page refresh
|
||||
setIsDismissed(true);
|
||||
};
|
||||
|
||||
|
|
@ -52,6 +52,9 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
|
|||
|
||||
const handleSignUpSuccess = () => {
|
||||
setShowSignUp(false);
|
||||
// Dismiss the banner when user signs in successfully
|
||||
// No need to persist - the parent condition (!session.authed) will hide us
|
||||
setIsDismissed(true);
|
||||
if (onAuthenticated) {
|
||||
onAuthenticated();
|
||||
}
|
||||
|
|
@ -61,107 +64,134 @@ const AnonymousViewerBanner: React.FC<AnonymousViewerBannerProps> = ({
|
|||
setShowSignUp(false);
|
||||
};
|
||||
|
||||
// Show CryptID modal when sign up is clicked
|
||||
if (showSignUp) {
|
||||
return (
|
||||
<div className="anonymous-banner-modal-overlay">
|
||||
<div className="anonymous-banner-modal">
|
||||
<CryptID
|
||||
onSuccess={handleSignUpSuccess}
|
||||
onCancel={handleSignUpCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`anonymous-viewer-banner ${triggeredByEdit ? 'edit-triggered' : ''} ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div className="banner-content">
|
||||
<div className="banner-icon">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" fill="currentColor"/>
|
||||
<div className={`anonymous-viewer-banner ${triggeredByEdit ? 'edit-triggered' : ''}`}>
|
||||
{/* Dismiss button in top-right corner */}
|
||||
{!triggeredByEdit && (
|
||||
<button
|
||||
className="banner-dismiss-btn"
|
||||
onClick={handleDismiss}
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="banner-text">
|
||||
{triggeredByEdit ? (
|
||||
<p className="banner-headline">
|
||||
<strong>Want to edit this board?</strong>
|
||||
</p>
|
||||
) : (
|
||||
<p className="banner-headline">
|
||||
<strong>You're viewing this board anonymously</strong>
|
||||
</p>
|
||||
)}
|
||||
<div className="banner-content">
|
||||
<div className="banner-header">
|
||||
<div className="banner-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z" fill="currentColor"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{isExpanded ? (
|
||||
<div className="banner-details">
|
||||
<p>
|
||||
Sign in by creating a username as your <strong>CryptID</strong> — no password required!
|
||||
<div className="banner-text">
|
||||
{triggeredByEdit ? (
|
||||
<p className="banner-headline">
|
||||
<strong>Sign in to edit</strong>
|
||||
</p>
|
||||
<ul className="cryptid-benefits">
|
||||
<li>
|
||||
<span className="benefit-icon">🔒</span>
|
||||
<span>Secured with encrypted keys, right in your browser, by a <a href="https://www.w3.org/TR/WebCryptoAPI/" target="_blank" rel="noopener noreferrer">W3C standard</a> algorithm</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="benefit-icon">💾</span>
|
||||
<span>Your session is stored for offline access, encrypted in browser storage by the same key</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="benefit-icon">📦</span>
|
||||
<span>Full data portability — use your canvas securely any time you like</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
) : (
|
||||
<p className="banner-headline">
|
||||
<strong>Viewing anonymously</strong>
|
||||
</p>
|
||||
)}
|
||||
<p className="banner-summary">
|
||||
Create a free CryptID to edit this board — no password needed!
|
||||
Sign in with CryptID to edit
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<div className="banner-actions">
|
||||
<button
|
||||
className="banner-signup-btn"
|
||||
onClick={handleSignUpClick}
|
||||
>
|
||||
Create CryptID
|
||||
Sign in
|
||||
</button>
|
||||
|
||||
{!triggeredByEdit && (
|
||||
<button
|
||||
className="banner-dismiss-btn"
|
||||
onClick={handleDismiss}
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isExpanded && (
|
||||
<button
|
||||
className="banner-expand-btn"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
title="Learn more"
|
||||
>
|
||||
Learn more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{triggeredByEdit && (
|
||||
<div className="banner-edit-notice">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span>This board is in read-only mode for anonymous viewers</span>
|
||||
<span>Read-only for anonymous viewers</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CryptID Sign In Modal - same as CryptIDDropdown */}
|
||||
{showSignUp && createPortal(
|
||||
<div
|
||||
className="cryptid-modal-overlay"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 999999,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
handleSignUpCancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="cryptid-modal"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-panel, #ffffff)',
|
||||
borderRadius: '16px',
|
||||
padding: '0',
|
||||
maxWidth: '580px',
|
||||
width: '95vw',
|
||||
maxHeight: '90vh',
|
||||
boxShadow: '0 25px 80px rgba(0, 0, 0, 0.4)',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleSignUpCancel}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
right: '12px',
|
||||
background: 'var(--color-muted-2, #f3f4f6)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--color-text-2, #6b7280)',
|
||||
fontSize: '16px',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<CryptID
|
||||
onSuccess={handleSignUpSuccess}
|
||||
onCancel={handleSignUpCancel}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useAuth } from '../../context/AuthContext';
|
|||
import { useNotifications } from '../../context/NotificationContext';
|
||||
import { checkBrowserSupport, isSecureContext } from '../../lib/utils/browser';
|
||||
import { WORKER_URL } from '../../constants/workerUrl';
|
||||
import '../../css/crypto-auth.css'; // For spin animation
|
||||
|
||||
interface CryptIDProps {
|
||||
onSuccess?: () => void;
|
||||
|
|
@ -26,6 +27,8 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
|||
const [existingUsers, setExistingUsers] = useState<string[]>([]);
|
||||
const [suggestedUsername, setSuggestedUsername] = useState<string>('');
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
const [usernameAvailable, setUsernameAvailable] = useState<boolean | null>(null);
|
||||
const [checkingUsername, setCheckingUsername] = useState(false);
|
||||
const [browserSupport, setBrowserSupport] = useState<{
|
||||
supported: boolean;
|
||||
secure: boolean;
|
||||
|
|
@ -97,6 +100,45 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
|||
checkExistingUsers();
|
||||
}, [addNotification]);
|
||||
|
||||
// Check username availability with debounce
|
||||
useEffect(() => {
|
||||
// Only check when registering and on username step
|
||||
if (!isRegistering || registrationStep !== 'username') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset availability when username changes
|
||||
setUsernameAvailable(null);
|
||||
setError(null);
|
||||
|
||||
// Don't check if username is too short
|
||||
if (username.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce the check
|
||||
const timeoutId = setTimeout(async () => {
|
||||
setCheckingUsername(true);
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/api/auth/check-username?username=${encodeURIComponent(username)}`);
|
||||
const data = await response.json() as { available: boolean; error?: string };
|
||||
|
||||
setUsernameAvailable(data.available);
|
||||
if (!data.available && data.error) {
|
||||
setError(data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking username:', err);
|
||||
// On network error, allow proceeding (server will validate on registration)
|
||||
setUsernameAvailable(null);
|
||||
} finally {
|
||||
setCheckingUsername(false);
|
||||
}
|
||||
}, 500); // Wait 500ms after user stops typing
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [username, isRegistering, registrationStep]);
|
||||
|
||||
/**
|
||||
* Send backup email with magic link
|
||||
*/
|
||||
|
|
@ -160,8 +202,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
|||
/**
|
||||
* Handle login
|
||||
*/
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleLogin = async () => {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
|
|
@ -240,20 +281,34 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
|||
<div style={styles.explainerItem}>
|
||||
<span style={styles.explainerIcon}>🔑</span>
|
||||
<div>
|
||||
<strong>Cryptographic Keys</strong>
|
||||
<strong>No Password Needed</strong>
|
||||
<p style={styles.explainerText}>
|
||||
When you create an account, your browser generates a unique cryptographic key pair.
|
||||
The private key never leaves your device.
|
||||
Encrypted keys are created directly on your device using the{' '}
|
||||
<a href="https://w3c.github.io/webcrypto/" target="_blank" rel="noopener noreferrer" style={{ color: '#8b5cf6' }}>
|
||||
W3C Web Cryptography API
|
||||
</a>{' '}
|
||||
standard. Your identity and data are secured locally - no passwords to remember or leak.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.explainerItem}>
|
||||
<span style={styles.explainerIcon}>💾</span>
|
||||
<div>
|
||||
<strong>Secure Storage</strong>
|
||||
<strong>Secure Browser Storage</strong>
|
||||
<p style={styles.explainerText}>
|
||||
Your keys are stored securely in your browser using WebCryptoAPI -
|
||||
the same technology used by banks and governments.
|
||||
Your cryptographic keys encrypt your data locally using local-first architecture.
|
||||
This means you control what you share - your data sovereignty is protected by default
|
||||
for individuals and groups alike.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.explainerItem}>
|
||||
<span style={styles.explainerIcon}>📧</span>
|
||||
<div>
|
||||
<strong>Link Your Email</strong>
|
||||
<p style={styles.explainerText}>
|
||||
Add an email address to connect to your account from other devices.
|
||||
We'll send you a secure link to establish trust between devices.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -262,8 +317,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
|||
<div>
|
||||
<strong>Multi-Device Access</strong>
|
||||
<p style={styles.explainerText}>
|
||||
Add your email to receive a backup link. Open it on another device
|
||||
(like your phone) to sync your account securely.
|
||||
Add a mobile device or tablet and link keys for one streamlined identity across all your devices.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -272,14 +326,14 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
|||
|
||||
<div style={styles.featureList}>
|
||||
<div style={styles.featureItem}>
|
||||
<span style={{ color: '#22c55e' }}>✓</span> No password to remember or lose
|
||||
<span style={{ color: '#22c55e' }}>✓</span> Built on W3C cryptography standards
|
||||
</div>
|
||||
<div style={styles.featureItem}>
|
||||
<span style={{ color: '#22c55e' }}>✓</span> Local-first data sovereignty
|
||||
</div>
|
||||
<div style={styles.featureItem}>
|
||||
<span style={{ color: '#22c55e' }}>✓</span> Phishing-resistant authentication
|
||||
</div>
|
||||
<div style={styles.featureItem}>
|
||||
<span style={{ color: '#22c55e' }}>✓</span> Your data stays encrypted
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
|
@ -296,7 +350,7 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
|||
setUsername(existingUsers[0]);
|
||||
}
|
||||
}}
|
||||
style={styles.linkButton}
|
||||
style={{ ...styles.linkButton, marginTop: '20px' }}
|
||||
>
|
||||
Already have an account? Sign in
|
||||
</button>
|
||||
|
|
@ -310,24 +364,82 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
|||
<h2 style={styles.title}>Choose Your Username</h2>
|
||||
<p style={styles.subtitle}>This is your unique identity on the platform</p>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); setRegistrationStep('email'); }}>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (usernameAvailable !== false && !checkingUsername) {
|
||||
setRegistrationStep('email');
|
||||
}
|
||||
}}>
|
||||
<div style={styles.inputGroup}>
|
||||
<label style={styles.label}>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
|
||||
placeholder="e.g., alex_smith"
|
||||
style={styles.input}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={20}
|
||||
autoFocus
|
||||
/>
|
||||
<p style={styles.hint}>3-20 characters, lowercase letters, numbers, _ and -</p>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
|
||||
placeholder="e.g., alex_smith"
|
||||
style={{
|
||||
...styles.input,
|
||||
paddingRight: '40px',
|
||||
borderColor: username.length >= 3
|
||||
? (usernameAvailable === true ? '#22c55e'
|
||||
: usernameAvailable === false ? '#ef4444'
|
||||
: undefined)
|
||||
: undefined,
|
||||
}}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={20}
|
||||
autoFocus
|
||||
/>
|
||||
{/* Availability indicator */}
|
||||
{username.length >= 3 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
right: '12px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{checkingUsername ? (
|
||||
<div style={{
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
border: '2px solid #a0a0b0',
|
||||
borderTopColor: 'transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}} />
|
||||
) : usernameAvailable === true ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : usernameAvailable === false ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p style={{
|
||||
...styles.hint,
|
||||
color: usernameAvailable === true ? '#22c55e'
|
||||
: usernameAvailable === false ? '#ef4444'
|
||||
: undefined,
|
||||
}}>
|
||||
{usernameAvailable === true
|
||||
? 'Username is available!'
|
||||
: usernameAvailable === false
|
||||
? 'Username is already taken'
|
||||
: '3-20 characters, lowercase letters, numbers, _ and -'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
{error && !usernameAvailable && <div style={styles.error}>{error}</div>}
|
||||
|
||||
<div style={styles.buttonGroup}>
|
||||
<button
|
||||
|
|
@ -339,14 +451,14 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
|||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={username.length < 3}
|
||||
disabled={username.length < 3 || usernameAvailable === false || checkingUsername}
|
||||
style={{
|
||||
...styles.primaryButton,
|
||||
opacity: username.length < 3 ? 0.5 : 1,
|
||||
cursor: username.length < 3 ? 'not-allowed' : 'pointer',
|
||||
opacity: (username.length < 3 || usernameAvailable === false || checkingUsername) ? 0.5 : 1,
|
||||
cursor: (username.length < 3 || usernameAvailable === false || checkingUsername) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
{checkingUsername ? 'Checking...' : 'Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -457,11 +569,6 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{onCancel && registrationStep !== 'success' && (
|
||||
<button onClick={onCancel} style={styles.cancelButton}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -473,59 +580,55 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
|||
<div style={styles.iconLarge}>🔐</div>
|
||||
<h2 style={styles.title}>Sign In with CryptID</h2>
|
||||
|
||||
{existingUsers.length > 0 && (
|
||||
<div style={styles.existingUsers}>
|
||||
<p style={styles.existingUsersLabel}>Your accounts on this device:</p>
|
||||
<div style={styles.userList}>
|
||||
{existingUsers.map((user) => (
|
||||
<button
|
||||
key={user}
|
||||
onClick={() => setUsername(user)}
|
||||
style={{
|
||||
...styles.userButton,
|
||||
borderColor: username === user ? '#8b5cf6' : '#e5e7eb',
|
||||
backgroundColor: username === user ? 'rgba(139, 92, 246, 0.1)' : 'transparent',
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span style={styles.userIcon}>🔑</span>
|
||||
<span style={styles.userName}>{user}</span>
|
||||
{username === user && <span style={styles.selectedBadge}>Selected</span>}
|
||||
</button>
|
||||
))}
|
||||
{existingUsers.length > 0 ? (
|
||||
<>
|
||||
<div style={styles.existingUsers}>
|
||||
<p style={styles.existingUsersLabel}>Select your account:</p>
|
||||
<div style={styles.userList}>
|
||||
{existingUsers.map((user) => (
|
||||
<button
|
||||
key={user}
|
||||
onClick={() => setUsername(user)}
|
||||
style={{
|
||||
...styles.userButton,
|
||||
borderColor: username === user ? '#8b5cf6' : '#e5e7eb',
|
||||
backgroundColor: username === user ? 'rgba(139, 92, 246, 0.1)' : 'transparent',
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span style={styles.userIcon}>🔑</span>
|
||||
<span style={styles.userName}>{user}</span>
|
||||
{username === user && <span style={styles.selectedBadge}>Selected</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading || !username.trim()}
|
||||
style={{
|
||||
...styles.primaryButton,
|
||||
opacity: (isLoading || !username.trim()) ? 0.5 : 1,
|
||||
cursor: (isLoading || !username.trim()) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Signing In...' : 'Sign In'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<p style={{ ...styles.subtitle, marginBottom: '16px' }}>
|
||||
No accounts found on this device.
|
||||
</p>
|
||||
<p style={{ ...styles.hint, marginBottom: '20px' }}>
|
||||
Create a new CryptID or use a backup link from another device to sign in here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<div style={styles.inputGroup}>
|
||||
<label style={styles.label}>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username"
|
||||
style={styles.input}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !username.trim()}
|
||||
style={{
|
||||
...styles.primaryButton,
|
||||
opacity: (isLoading || !username.trim()) ? 0.5 : 1,
|
||||
cursor: (isLoading || !username.trim()) ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Signing In...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRegistering(true);
|
||||
|
|
@ -533,189 +636,182 @@ const CryptID: React.FC<CryptIDProps> = ({ onSuccess, onCancel }) => {
|
|||
setUsername('');
|
||||
setError(null);
|
||||
}}
|
||||
style={styles.linkButton}
|
||||
style={existingUsers.length > 0 ? { ...styles.linkButton, marginTop: '20px' } : styles.primaryButton}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Need an account? Create one
|
||||
{existingUsers.length > 0 ? 'Need an account? Create one' : 'Create a CryptID'}
|
||||
</button>
|
||||
|
||||
{onCancel && (
|
||||
<button onClick={onCancel} style={styles.cancelButton}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Styles
|
||||
// Styles - compact layout to fit on one screen (updated 2025-12-12)
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: '20px',
|
||||
maxWidth: '440px',
|
||||
padding: '16px',
|
||||
maxWidth: '540px',
|
||||
margin: '0 auto',
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
backgroundColor: 'var(--color-panel, #fff)',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.1)',
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
},
|
||||
errorCard: {
|
||||
width: '100%',
|
||||
backgroundColor: '#fef2f2',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
},
|
||||
stepIndicator: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: '24px',
|
||||
marginBottom: '16px',
|
||||
gap: '0',
|
||||
},
|
||||
stepDot: {
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
},
|
||||
stepLine: {
|
||||
width: '40px',
|
||||
width: '32px',
|
||||
height: '2px',
|
||||
backgroundColor: '#e5e7eb',
|
||||
},
|
||||
iconLarge: {
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
fontSize: '36px',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
errorIcon: {
|
||||
fontSize: '48px',
|
||||
marginBottom: '16px',
|
||||
fontSize: '36px',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
successIcon: {
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#22c55e',
|
||||
color: 'white',
|
||||
fontSize: '32px',
|
||||
fontSize: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 16px',
|
||||
margin: '0 auto 12px',
|
||||
},
|
||||
title: {
|
||||
fontSize: '24px',
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text, #1f2937)',
|
||||
marginBottom: '8px',
|
||||
margin: '0 0 8px 0',
|
||||
marginBottom: '4px',
|
||||
margin: '0 0 4px 0',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: '14px',
|
||||
fontSize: '13px',
|
||||
color: 'var(--color-text-3, #6b7280)',
|
||||
marginBottom: '24px',
|
||||
margin: '0 0 24px 0',
|
||||
marginBottom: '16px',
|
||||
margin: '0 0 16px 0',
|
||||
},
|
||||
description: {
|
||||
fontSize: '14px',
|
||||
fontSize: '13px',
|
||||
color: '#6b7280',
|
||||
lineHeight: 1.6,
|
||||
marginBottom: '24px',
|
||||
lineHeight: 1.5,
|
||||
marginBottom: '16px',
|
||||
},
|
||||
explainerBox: {
|
||||
backgroundColor: 'var(--color-muted-2, #f9fafb)',
|
||||
borderRadius: '12px',
|
||||
padding: '20px',
|
||||
marginBottom: '24px',
|
||||
borderRadius: '10px',
|
||||
padding: '14px',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'left',
|
||||
},
|
||||
explainerTitle: {
|
||||
fontSize: '14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text, #1f2937)',
|
||||
marginBottom: '16px',
|
||||
margin: '0 0 16px 0',
|
||||
marginBottom: '12px',
|
||||
margin: '0 0 12px 0',
|
||||
},
|
||||
explainerContent: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
gap: '10px',
|
||||
},
|
||||
explainerItem: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
gap: '10px',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
explainerIcon: {
|
||||
fontSize: '20px',
|
||||
fontSize: '16px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
explainerText: {
|
||||
fontSize: '12px',
|
||||
fontSize: '11px',
|
||||
color: 'var(--color-text-3, #6b7280)',
|
||||
margin: '4px 0 0 0',
|
||||
lineHeight: 1.5,
|
||||
margin: '2px 0 0 0',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
featureList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
marginBottom: '24px',
|
||||
gap: '6px',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
featureItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '13px',
|
||||
gap: '6px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text, #374151)',
|
||||
},
|
||||
infoBox: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
gap: '10px',
|
||||
padding: '12px',
|
||||
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
||||
borderRadius: '10px',
|
||||
marginBottom: '20px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '14px',
|
||||
textAlign: 'left',
|
||||
},
|
||||
infoIcon: {
|
||||
fontSize: '20px',
|
||||
fontSize: '16px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: '13px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text, #374151)',
|
||||
margin: 0,
|
||||
lineHeight: 1.5,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
successBox: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
padding: '16px',
|
||||
gap: '8px',
|
||||
padding: '12px',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
borderRadius: '10px',
|
||||
marginBottom: '20px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '14px',
|
||||
},
|
||||
successItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '14px',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text, #374151)',
|
||||
},
|
||||
successCheck: {
|
||||
|
|
@ -723,22 +819,22 @@ const styles: Record<string, React.CSSProperties> = {
|
|||
fontWeight: 600,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: '20px',
|
||||
marginBottom: '14px',
|
||||
textAlign: 'left',
|
||||
},
|
||||
label: {
|
||||
display: 'block',
|
||||
fontSize: '13px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-text, #374151)',
|
||||
marginBottom: '6px',
|
||||
marginBottom: '4px',
|
||||
},
|
||||
input: {
|
||||
width: '100%',
|
||||
padding: '12px 14px',
|
||||
fontSize: '15px',
|
||||
padding: '10px 12px',
|
||||
fontSize: '14px',
|
||||
border: '2px solid var(--color-panel-contrast, #e5e7eb)',
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--color-panel, #fff)',
|
||||
color: 'var(--color-text, #1f2937)',
|
||||
outline: 'none',
|
||||
|
|
@ -746,88 +842,79 @@ const styles: Record<string, React.CSSProperties> = {
|
|||
boxSizing: 'border-box',
|
||||
},
|
||||
hint: {
|
||||
fontSize: '11px',
|
||||
fontSize: '10px',
|
||||
color: 'var(--color-text-3, #9ca3af)',
|
||||
marginTop: '6px',
|
||||
marginTop: '4px',
|
||||
margin: '6px 0 0 0',
|
||||
},
|
||||
error: {
|
||||
padding: '12px',
|
||||
padding: '10px',
|
||||
backgroundColor: '#fef2f2',
|
||||
color: '#dc2626',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
marginBottom: '16px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
marginBottom: '12px',
|
||||
},
|
||||
buttonGroup: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
gap: '10px',
|
||||
},
|
||||
primaryButton: {
|
||||
flex: 1,
|
||||
padding: '14px 24px',
|
||||
fontSize: '15px',
|
||||
padding: '10px 18px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.15s, box-shadow 0.15s',
|
||||
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)',
|
||||
},
|
||||
secondaryButton: {
|
||||
flex: 1,
|
||||
padding: '14px 24px',
|
||||
fontSize: '15px',
|
||||
padding: '10px 18px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-text, #374151)',
|
||||
backgroundColor: 'var(--color-muted-2, #f3f4f6)',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
linkButton: {
|
||||
marginTop: '16px',
|
||||
padding: '8px',
|
||||
fontSize: '13px',
|
||||
marginTop: '12px',
|
||||
padding: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#8b5cf6',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
cancelButton: {
|
||||
marginTop: '16px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
color: 'var(--color-text-3, #6b7280)',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
existingUsers: {
|
||||
marginBottom: '20px',
|
||||
marginBottom: '14px',
|
||||
textAlign: 'left',
|
||||
},
|
||||
existingUsersLabel: {
|
||||
fontSize: '13px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--color-text-3, #6b7280)',
|
||||
marginBottom: '10px',
|
||||
margin: '0 0 10px 0',
|
||||
marginBottom: '8px',
|
||||
margin: '0 0 8px 0',
|
||||
},
|
||||
userList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
gap: '6px',
|
||||
},
|
||||
userButton: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '12px 14px',
|
||||
gap: '8px',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s',
|
||||
|
|
@ -835,20 +922,20 @@ const styles: Record<string, React.CSSProperties> = {
|
|||
textAlign: 'left',
|
||||
},
|
||||
userIcon: {
|
||||
fontSize: '18px',
|
||||
fontSize: '16px',
|
||||
},
|
||||
userName: {
|
||||
flex: 1,
|
||||
fontSize: '14px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-text, #374151)',
|
||||
},
|
||||
selectedBadge: {
|
||||
fontSize: '11px',
|
||||
padding: '2px 8px',
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
backgroundColor: '#8b5cf6',
|
||||
color: 'white',
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 500,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { useEditor, useValue } from 'tldraw';
|
||||
import CryptID from './CryptID';
|
||||
import { GoogleDataService, type GoogleService } from '../../lib/google';
|
||||
import { GoogleExportBrowser } from '../GoogleExportBrowser';
|
||||
import { getFathomApiKey, saveFathomApiKey, removeFathomApiKey, isFathomApiKeyConfigured } from '../../lib/fathomApiKey';
|
||||
import { isMiroApiKeyConfigured } from '../../lib/miroApiKey';
|
||||
import { MiroIntegrationModal } from '../MiroIntegrationModal';
|
||||
import { getMyConnections, createConnection, removeConnection, updateTrustLevel, updateEdgeMetadata } from '../../lib/networking/connectionService';
|
||||
import { TRUST_LEVEL_COLORS, type TrustLevel, type UserConnectionWithProfile, type EdgeMetadata } from '../../lib/networking/types';
|
||||
|
||||
|
|
@ -22,6 +25,7 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
const [showCryptIDModal, setShowCryptIDModal] = useState(false);
|
||||
const [showGoogleBrowser, setShowGoogleBrowser] = useState(false);
|
||||
const [showObsidianModal, setShowObsidianModal] = useState(false);
|
||||
const [showMiroModal, setShowMiroModal] = useState(false);
|
||||
const [obsidianVaultUrl, setObsidianVaultUrl] = useState('');
|
||||
const [googleConnected, setGoogleConnected] = useState(false);
|
||||
const [googleLoading, setGoogleLoading] = useState(false);
|
||||
|
|
@ -32,6 +36,7 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
calendar: 0,
|
||||
});
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Expanded sections (only integrations and connections now)
|
||||
const [expandedSection, setExpandedSection] = useState<'none' | 'integrations' | 'connections'>('none');
|
||||
|
|
@ -49,15 +54,10 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
const [savingMetadata, setSavingMetadata] = useState(false);
|
||||
const [connectingUserId, setConnectingUserId] = useState<string | null>(null);
|
||||
|
||||
// Try to get editor (may not exist if outside tldraw context)
|
||||
let editor: any = null;
|
||||
let collaborators: any[] = [];
|
||||
try {
|
||||
editor = useEditor();
|
||||
collaborators = useValue('collaborators', () => editor?.getCollaborators() || [], [editor]) || [];
|
||||
} catch {
|
||||
// Not inside tldraw context
|
||||
}
|
||||
// Get editor - will throw if outside tldraw context, but that's handled by ErrorBoundary
|
||||
// Note: These hooks must always be called unconditionally
|
||||
const editorFromHook = useEditor();
|
||||
const collaborators = useValue('collaborators', () => editorFromHook?.getCollaborators() || [], [editorFromHook]) || [];
|
||||
|
||||
// Canvas users with their connection status
|
||||
interface CanvasUser {
|
||||
|
|
@ -190,7 +190,11 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
// Close dropdown when clicking outside or pressing ESC
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
const target = e.target as Node;
|
||||
// Check if click is inside trigger button OR the portal dropdown menu
|
||||
const isInsideTrigger = dropdownRef.current && dropdownRef.current.contains(target);
|
||||
const isInsideMenu = dropdownMenuRef.current && dropdownMenuRef.current.contains(target);
|
||||
if (!isInsideTrigger && !isInsideMenu) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -257,25 +261,40 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
return name.charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
// If showing CryptID modal
|
||||
if (showCryptIDModal) {
|
||||
return (
|
||||
<div className="cryptid-modal-overlay">
|
||||
<div className="cryptid-modal">
|
||||
<CryptID
|
||||
onSuccess={() => setShowCryptIDModal(false)}
|
||||
onCancel={() => setShowCryptIDModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Ref for the trigger button to calculate dropdown position
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
|
||||
|
||||
// Update dropdown position when it opens
|
||||
useEffect(() => {
|
||||
if (showDropdown && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + 8,
|
||||
right: window.innerWidth - rect.right,
|
||||
});
|
||||
}
|
||||
}, [showDropdown]);
|
||||
|
||||
// Close dropdown when user logs out
|
||||
useEffect(() => {
|
||||
if (!session.authed) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}, [session.authed]);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="cryptid-dropdown" style={{ position: 'relative', pointerEvents: 'all' }}>
|
||||
{/* Trigger button */}
|
||||
<div ref={dropdownRef} className="cryptid-dropdown" style={{ pointerEvents: 'all' }}>
|
||||
{/* Trigger button - opens modal directly for unauthenticated users, dropdown for authenticated */}
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
ref={triggerRef}
|
||||
onClick={() => {
|
||||
if (session.authed) {
|
||||
setShowDropdown(!showDropdown);
|
||||
} else {
|
||||
setShowCryptIDModal(true);
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="cryptid-trigger"
|
||||
style={{
|
||||
|
|
@ -295,82 +314,78 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
>
|
||||
{session.authed ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{getInitials(session.username)}
|
||||
</div>
|
||||
{/* Locked lock icon */}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
<span style={{ fontSize: '13px', fontWeight: 500, maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{session.username}
|
||||
</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Unlocked lock icon */}
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
|
||||
</svg>
|
||||
<span style={{ fontSize: '13px', fontWeight: 500 }}>Sign In</span>
|
||||
</>
|
||||
)}
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{showDropdown && (
|
||||
|
||||
{/* Dropdown menu - rendered via portal to break out of parent container */}
|
||||
{showDropdown && dropdownPosition && createPortal(
|
||||
<div
|
||||
ref={dropdownMenuRef}
|
||||
className="cryptid-dropdown-menu"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 8px)',
|
||||
right: 0,
|
||||
position: 'fixed',
|
||||
top: dropdownPosition.top,
|
||||
right: dropdownPosition.right,
|
||||
minWidth: '260px',
|
||||
maxHeight: 'calc(100vh - 100px)',
|
||||
background: 'var(--color-panel)',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
||||
background: 'var(--color-background)',
|
||||
border: '1px solid var(--color-grid)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.05)',
|
||||
zIndex: 100000,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
pointerEvents: 'all',
|
||||
fontFamily: 'var(--tl-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif)',
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
// Stop wheel events from propagating to canvas when over menu
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
onTouchEnd={(e) => e.stopPropagation()}
|
||||
>
|
||||
{session.authed ? (
|
||||
<>
|
||||
{/* Account section */}
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid var(--color-panel-contrast)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ padding: '12px 14px', borderBottom: '1px solid var(--color-grid)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '6px',
|
||||
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '16px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
}}
|
||||
|
|
@ -378,79 +393,80 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
{getInitials(session.username)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{session.username}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ color: '#22c55e' }}>🔒</span> CryptID secured
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-2)', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="#22c55e" stroke="#22c55e" strokeWidth="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
CryptID secured
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div style={{ padding: '8px 0', borderBottom: '1px solid var(--color-panel-contrast)' }}>
|
||||
<div style={{ padding: '4px', borderBottom: '1px solid var(--color-grid)' }}>
|
||||
<a
|
||||
href="/dashboard/"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 16px',
|
||||
gap: '8px',
|
||||
padding: '8px 10px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-text)',
|
||||
textDecoration: 'none',
|
||||
transition: 'background 0.15s, transform 0.15s',
|
||||
borderRadius: '6px',
|
||||
margin: '0 8px',
|
||||
transition: 'background 0.1s',
|
||||
borderRadius: '4px',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)';
|
||||
e.currentTarget.style.transform = 'translateX(2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.transform = 'translateX(0)';
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="#f59e0b" stroke="#f59e0b" strokeWidth="1">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="var(--color-text-2)" stroke="none">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||
</svg>
|
||||
My Saved Boards
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginLeft: 'auto', opacity: 0.5 }}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-2)" strokeWidth="2" style={{ marginLeft: 'auto' }}>
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Integrations section */}
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<div style={{ padding: '4px' }}>
|
||||
<div style={{
|
||||
padding: '8px 16px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-3)',
|
||||
padding: '6px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-text-2)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
letterSpacing: '0.3px',
|
||||
}}>
|
||||
Integrations
|
||||
</div>
|
||||
|
||||
{/* Google Workspace */}
|
||||
<div style={{ padding: '8px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||
<div style={{ padding: '6px 10px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
background: 'linear-gradient(135deg, #4285F4, #34A853, #FBBC04, #EA4335)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: 'white',
|
||||
}}>
|
||||
|
|
@ -460,21 +476,21 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||
Google Workspace
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
|
||||
{googleConnected ? `${totalGoogleItems} items imported` : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
{googleConnected && (
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#22c55e',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{googleConnected ? (
|
||||
<>
|
||||
<button
|
||||
|
|
@ -485,16 +501,25 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 14px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '6px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #4285F4, #34A853)',
|
||||
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 2px 8px rgba(66, 133, 244, 0.3)',
|
||||
pointerEvents: 'all',
|
||||
transition: 'all 0.15s',
|
||||
boxShadow: '0 2px 4px rgba(139, 92, 246, 0.3)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
|
||||
}}
|
||||
>
|
||||
Browse Data
|
||||
|
|
@ -503,15 +528,22 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
onClick={handleGoogleDisconnect}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
backgroundColor: 'var(--color-muted-2)',
|
||||
color: 'var(--color-text)',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'all',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#4b5563';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#6b7280';
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
|
|
@ -524,26 +556,28 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
disabled={googleLoading}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 16px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '6px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #4285F4, #34A853)',
|
||||
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||
color: 'white',
|
||||
cursor: googleLoading ? 'wait' : 'pointer',
|
||||
opacity: googleLoading ? 0.7 : 1,
|
||||
transition: 'transform 0.15s, box-shadow 0.15s',
|
||||
boxShadow: '0 2px 8px rgba(66, 133, 244, 0.3)',
|
||||
transition: 'all 0.15s',
|
||||
pointerEvents: 'all',
|
||||
boxShadow: '0 2px 4px rgba(139, 92, 246, 0.3)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(66, 133, 244, 0.4)';
|
||||
if (!googleLoading) {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(66, 133, 244, 0.3)';
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
|
||||
}}
|
||||
>
|
||||
{googleLoading ? 'Connecting...' : 'Connect Google'}
|
||||
|
|
@ -553,17 +587,17 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
</div>
|
||||
|
||||
{/* Obsidian Vault */}
|
||||
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-panel-contrast)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||
<div style={{ padding: '6px 10px', borderTop: '1px solid var(--color-grid)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
background: '#7c3aed',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
fontSize: '12px',
|
||||
}}>
|
||||
📁
|
||||
</div>
|
||||
|
|
@ -571,14 +605,14 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||
Obsidian Vault
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
|
||||
{session.obsidianVaultName || 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
{session.obsidianVaultName && (
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#22c55e',
|
||||
}} />
|
||||
|
|
@ -592,29 +626,37 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 16px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '6px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: session.obsidianVaultName
|
||||
? 'var(--color-muted-2)'
|
||||
: 'linear-gradient(135deg, #7c3aed 0%, #a855f7 100%)',
|
||||
color: session.obsidianVaultName ? 'var(--color-text)' : 'white',
|
||||
? '#6b7280'
|
||||
: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
boxShadow: session.obsidianVaultName ? 'none' : '0 2px 8px rgba(124, 58, 237, 0.3)',
|
||||
pointerEvents: 'all',
|
||||
transition: 'transform 0.15s, box-shadow 0.15s',
|
||||
transition: 'all 0.15s',
|
||||
boxShadow: session.obsidianVaultName
|
||||
? 'none'
|
||||
: '0 2px 4px rgba(139, 92, 246, 0.3)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!session.obsidianVaultName) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(124, 58, 237, 0.4)';
|
||||
if (session.obsidianVaultName) {
|
||||
e.currentTarget.style.background = '#4b5563';
|
||||
} else {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = session.obsidianVaultName ? 'none' : '0 2px 8px rgba(124, 58, 237, 0.3)';
|
||||
if (session.obsidianVaultName) {
|
||||
e.currentTarget.style.background = '#6b7280';
|
||||
} else {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{session.obsidianVaultName ? 'Change Vault' : 'Connect Vault'}
|
||||
|
|
@ -622,17 +664,17 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
</div>
|
||||
|
||||
{/* Fathom Meetings */}
|
||||
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-panel-contrast)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||
<div style={{ padding: '6px 10px', borderTop: '1px solid var(--color-grid)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
background: '#ef4444',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '14px',
|
||||
fontSize: '12px',
|
||||
}}>
|
||||
🎥
|
||||
</div>
|
||||
|
|
@ -640,14 +682,14 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||
Fathom Meetings
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-3)' }}>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
|
||||
{hasFathomApiKey ? 'Connected' : 'Not connected'}
|
||||
</div>
|
||||
</div>
|
||||
{hasFathomApiKey && (
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#22c55e',
|
||||
}} />
|
||||
|
|
@ -664,11 +706,12 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
fontSize: '11px',
|
||||
border: '1px solid var(--color-panel-contrast)',
|
||||
border: '1px solid var(--color-grid)',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '6px',
|
||||
background: 'var(--color-panel)',
|
||||
background: 'var(--color-background)',
|
||||
color: 'var(--color-text)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && fathomKeyInput.trim()) {
|
||||
|
|
@ -696,12 +739,12 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: '#3b82f6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
border: '1px solid #2563eb',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'all',
|
||||
|
|
@ -717,15 +760,22 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '6px 10px',
|
||||
fontSize: '11px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
backgroundColor: 'var(--color-muted-2)',
|
||||
border: 'none',
|
||||
backgroundColor: 'var(--color-low)',
|
||||
border: '1px solid var(--color-grid)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-text)',
|
||||
pointerEvents: 'all',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--color-low)';
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
|
@ -733,7 +783,7 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowFathomInput(true);
|
||||
|
|
@ -743,29 +793,37 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 16px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '6px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: hasFathomApiKey
|
||||
? 'var(--color-muted-2)'
|
||||
: 'linear-gradient(135deg, #ef4444 0%, #f97316 100%)',
|
||||
color: hasFathomApiKey ? 'var(--color-text)' : 'white',
|
||||
? '#6b7280'
|
||||
: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
boxShadow: hasFathomApiKey ? 'none' : '0 2px 8px rgba(239, 68, 68, 0.3)',
|
||||
pointerEvents: 'all',
|
||||
transition: 'transform 0.15s, box-shadow 0.15s',
|
||||
transition: 'all 0.15s',
|
||||
boxShadow: hasFathomApiKey
|
||||
? 'none'
|
||||
: '0 2px 4px rgba(139, 92, 246, 0.3)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!hasFathomApiKey) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(239, 68, 68, 0.4)';
|
||||
if (hasFathomApiKey) {
|
||||
e.currentTarget.style.background = '#4b5563';
|
||||
} else {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = hasFathomApiKey ? 'none' : '0 2px 8px rgba(239, 68, 68, 0.3)';
|
||||
if (hasFathomApiKey) {
|
||||
e.currentTarget.style.background = '#6b7280';
|
||||
} else {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasFathomApiKey ? 'Change Key' : 'Add API Key'}
|
||||
|
|
@ -778,15 +836,22 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
borderRadius: '6px',
|
||||
backgroundColor: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: '#6b7280',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'all',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#4b5563';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = '#6b7280';
|
||||
}}
|
||||
>
|
||||
Disconnect
|
||||
|
|
@ -795,10 +860,90 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Miro Board Import */}
|
||||
<div style={{ padding: '6px 10px', borderTop: '1px solid var(--color-grid)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
<div style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '4px',
|
||||
background: 'linear-gradient(135deg, #ffd02f 0%, #f2c94c 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M17.5 3H21V21H17.5L14 12L17.5 3Z" fill="#050038"/>
|
||||
<path d="M10.5 3H14L10.5 12L14 21H10.5L7 12L10.5 3Z" fill="#050038"/>
|
||||
<path d="M3 3H6.5L3 12L6.5 21H3V3Z" fill="#050038"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 500, color: 'var(--color-text)' }}>
|
||||
Miro Boards
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-2)' }}>
|
||||
{isMiroApiKeyConfigured(session.username) ? 'API connected' : 'Import via JSON'}
|
||||
</div>
|
||||
</div>
|
||||
{isMiroApiKeyConfigured(session.username) && (
|
||||
<span style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#22c55e',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMiroModal(true);
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '6px 12px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: isMiroApiKeyConfigured(session.username)
|
||||
? '#6b7280'
|
||||
: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'all',
|
||||
transition: 'all 0.15s',
|
||||
boxShadow: isMiroApiKeyConfigured(session.username)
|
||||
? 'none'
|
||||
: '0 2px 4px rgba(139, 92, 246, 0.3)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isMiroApiKeyConfigured(session.username)) {
|
||||
e.currentTarget.style.background = '#4b5563';
|
||||
} else {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 8px rgba(139, 92, 246, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isMiroApiKeyConfigured(session.username)) {
|
||||
e.currentTarget.style.background = '#6b7280';
|
||||
} else {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(139, 92, 246, 0.3)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
Import Miro Board
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sign out */}
|
||||
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-panel-contrast)' }}>
|
||||
<div style={{ padding: '8px 10px', borderTop: '1px solid var(--color-grid)' }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
|
|
@ -806,27 +951,29 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
fontSize: '13px',
|
||||
padding: '8px 16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
borderRadius: '6px',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--color-text-3)',
|
||||
backgroundColor: '#6b7280',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'var(--color-muted-2)';
|
||||
e.currentTarget.style.backgroundColor = '#4b5563';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.backgroundColor = '#6b7280';
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
|
|
@ -835,52 +982,24 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<div style={{ marginBottom: '12px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: 'var(--color-text)', marginBottom: '4px' }}>
|
||||
Sign in with CryptID
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--color-text-3)', lineHeight: 1.4 }}>
|
||||
Create a username to edit boards and sync your data across devices.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCryptIDModal(true);
|
||||
setShowDropdown(false);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 600,
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Create or Sign In
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Google Export Browser Modal */}
|
||||
{showGoogleBrowser && (
|
||||
{showGoogleBrowser && createPortal(
|
||||
<GoogleExportBrowser
|
||||
isOpen={showGoogleBrowser}
|
||||
onClose={() => setShowGoogleBrowser(false)}
|
||||
onAddToCanvas={handleAddToCanvas}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Obsidian Vault Connection Modal */}
|
||||
{showObsidianModal && (
|
||||
{showObsidianModal && createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
|
|
@ -1110,7 +1229,88 @@ const CryptIDDropdown: React.FC<CryptIDDropdownProps> = ({ isDarkMode = false })
|
|||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Miro Integration Modal */}
|
||||
{showMiroModal && createPortal(
|
||||
<MiroIntegrationModal
|
||||
isOpen={showMiroModal}
|
||||
onClose={() => setShowMiroModal(false)}
|
||||
username={session.username}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* CryptID Sign In Modal - rendered via portal */}
|
||||
{showCryptIDModal && createPortal(
|
||||
<div
|
||||
className="cryptid-modal-overlay"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 999999,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowCryptIDModal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="cryptid-modal"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-panel, #ffffff)',
|
||||
borderRadius: '16px',
|
||||
padding: '0',
|
||||
maxWidth: '580px',
|
||||
width: '95vw',
|
||||
maxHeight: '90vh',
|
||||
boxShadow: '0 25px 80px rgba(0, 0, 0, 0.4)',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => setShowCryptIDModal(false)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
right: '12px',
|
||||
background: 'var(--color-muted-2, #f3f4f6)',
|
||||
border: 'none',
|
||||
borderRadius: '50%',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--color-text-2, #6b7280)',
|
||||
fontSize: '16px',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<CryptID
|
||||
onSuccess={() => setShowCryptIDModal(false)}
|
||||
onCancel={() => setShowCryptIDModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import * as d3 from 'd3';
|
||||
import { type GraphNode, type GraphEdge, type TrustLevel, TRUST_LEVEL_COLORS } from '../../lib/networking';
|
||||
import { type GraphNode, type GraphEdge, type TrustLevel } from '../../lib/networking';
|
||||
import { UserSearchModal } from './UserSearchModal';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -32,6 +32,9 @@ interface NetworkGraphMinimapProps {
|
|||
onConnect: (userId: string, trustLevel?: TrustLevel) => Promise<void>;
|
||||
onDisconnect?: (connectionId: string) => Promise<void>;
|
||||
onNodeClick?: (node: GraphNode) => void;
|
||||
onGoToUser?: (node: GraphNode) => void;
|
||||
onFollowUser?: (node: GraphNode) => void;
|
||||
onOpenProfile?: (node: GraphNode) => void;
|
||||
onEdgeClick?: (edge: GraphEdge) => void;
|
||||
onExpandClick?: () => void;
|
||||
width?: number;
|
||||
|
|
@ -132,25 +135,6 @@ const getStyles = (isDarkMode: boolean) => ({
|
|||
collapsedIcon: {
|
||||
fontSize: '20px',
|
||||
},
|
||||
stats: {
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
padding: '6px 12px',
|
||||
borderTop: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.08)',
|
||||
fontSize: '11px',
|
||||
color: isDarkMode ? '#888' : '#6b7280',
|
||||
backgroundColor: isDarkMode ? 'rgba(0, 0, 0, 0.2)' : 'rgba(0, 0, 0, 0.02)',
|
||||
},
|
||||
stat: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
},
|
||||
statDot: {
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -165,6 +149,9 @@ export function NetworkGraphMinimap({
|
|||
onConnect,
|
||||
onDisconnect,
|
||||
onNodeClick,
|
||||
onGoToUser,
|
||||
onFollowUser,
|
||||
onOpenProfile,
|
||||
onEdgeClick,
|
||||
onExpandClick,
|
||||
width = 240,
|
||||
|
|
@ -183,13 +170,6 @@ export function NetworkGraphMinimap({
|
|||
// Get theme-aware styles
|
||||
const styles = React.useMemo(() => getStyles(isDarkMode), [isDarkMode]);
|
||||
|
||||
// Count stats
|
||||
const inRoomCount = nodes.filter(n => n.isInRoom).length;
|
||||
const anonymousCount = nodes.filter(n => n.isAnonymous).length;
|
||||
const trustedCount = nodes.filter(n => n.trustLevelTo === 'trusted').length;
|
||||
const connectedCount = nodes.filter(n => n.trustLevelTo === 'connected').length;
|
||||
const unconnectedCount = nodes.filter(n => !n.trustLevelTo && !n.isCurrentUser && !n.isAnonymous).length;
|
||||
|
||||
// Initialize and update the D3 simulation
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || isCollapsed || nodes.length === 0) return;
|
||||
|
|
@ -313,41 +293,18 @@ export function NetworkGraphMinimap({
|
|||
}
|
||||
});
|
||||
|
||||
// Helper to get node color based on trust level and room status
|
||||
// Priority: current user (purple) > anonymous (grey) > trust level > unconnected (white)
|
||||
// Helper to get node color - uses the user's profile/presence color
|
||||
const getNodeColor = (d: SimulationNode) => {
|
||||
if (d.isCurrentUser) {
|
||||
return '#4f46e5'; // Current user is always purple
|
||||
// Use room presence color (user's profile color) if available
|
||||
if (d.roomPresenceColor) {
|
||||
return d.roomPresenceColor;
|
||||
}
|
||||
// Anonymous users are grey
|
||||
if (d.isAnonymous) {
|
||||
return TRUST_LEVEL_COLORS.anonymous;
|
||||
// Use avatar color as fallback
|
||||
if (d.avatarColor) {
|
||||
return d.avatarColor;
|
||||
}
|
||||
// If in room and has presence color, use it for the stroke/ring instead
|
||||
// (we still use trust level for fill to maintain visual consistency)
|
||||
// Otherwise use trust level color
|
||||
if (d.trustLevelTo) {
|
||||
return TRUST_LEVEL_COLORS[d.trustLevelTo];
|
||||
}
|
||||
// Authenticated but unconnected = white
|
||||
return TRUST_LEVEL_COLORS.unconnected;
|
||||
};
|
||||
|
||||
// Helper to get node stroke color (for in-room presence indicator)
|
||||
const getNodeStroke = (d: SimulationNode) => {
|
||||
if (d.isCurrentUser) return '#fff';
|
||||
// Show room presence color as a ring around the node
|
||||
if (d.isInRoom && d.roomPresenceColor) return d.roomPresenceColor;
|
||||
// White nodes need a subtle border to be visible
|
||||
if (!d.isAnonymous && !d.trustLevelTo) return '#e5e7eb';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
const getNodeStrokeWidth = (d: SimulationNode) => {
|
||||
if (d.isCurrentUser) return 2;
|
||||
if (d.isInRoom && d.roomPresenceColor) return 2;
|
||||
if (!d.isAnonymous && !d.trustLevelTo) return 1;
|
||||
return 0;
|
||||
// Default grey for users without a color
|
||||
return '#9ca3af';
|
||||
};
|
||||
|
||||
// Create nodes
|
||||
|
|
@ -358,15 +315,13 @@ export function NetworkGraphMinimap({
|
|||
.join('circle')
|
||||
.attr('r', d => d.isCurrentUser ? 8 : 6)
|
||||
.attr('fill', d => getNodeColor(d))
|
||||
.attr('stroke', d => getNodeStroke(d))
|
||||
.attr('stroke-width', d => getNodeStrokeWidth(d))
|
||||
.style('cursor', 'pointer')
|
||||
.on('mouseenter', (event, d) => {
|
||||
const rect = svgRef.current!.getBoundingClientRect();
|
||||
setTooltip({
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top,
|
||||
text: `${d.displayName || d.username}${d.isAnonymous ? ' (anonymous)' : ''}`,
|
||||
text: d.displayName || d.username,
|
||||
});
|
||||
})
|
||||
.on('mouseleave', () => {
|
||||
|
|
@ -374,12 +329,12 @@ export function NetworkGraphMinimap({
|
|||
})
|
||||
.on('click', (event, d) => {
|
||||
event.stopPropagation();
|
||||
// Don't show popup for current user or anonymous users
|
||||
if (d.isCurrentUser || d.isAnonymous) {
|
||||
// Don't show popup for current user
|
||||
if (d.isCurrentUser) {
|
||||
if (onNodeClick) onNodeClick(d);
|
||||
return;
|
||||
}
|
||||
// Show connection popup
|
||||
// Show dropdown menu for all other users
|
||||
const rect = svgRef.current!.getBoundingClientRect();
|
||||
setSelectedNode({
|
||||
node: d,
|
||||
|
|
@ -502,187 +457,142 @@ export function NetworkGraphMinimap({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection popup when clicking a node */}
|
||||
{selectedNode && !selectedNode.node.isCurrentUser && !selectedNode.node.isAnonymous && (
|
||||
{/* User action dropdown menu when clicking a node */}
|
||||
{selectedNode && !selectedNode.node.isCurrentUser && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: Math.min(selectedNode.x, width - 140),
|
||||
top: Math.max(selectedNode.y - 80, 10),
|
||||
backgroundColor: 'white',
|
||||
left: Math.min(selectedNode.x, width - 160),
|
||||
top: Math.max(selectedNode.y - 10, 10),
|
||||
backgroundColor: isDarkMode ? '#1e1e2e' : 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
padding: '8px',
|
||||
boxShadow: isDarkMode ? '0 4px 12px rgba(0, 0, 0, 0.4)' : '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
padding: '6px',
|
||||
zIndex: 1002,
|
||||
minWidth: '130px',
|
||||
minWidth: '150px',
|
||||
border: isDarkMode ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ fontSize: '11px', fontWeight: 600, marginBottom: '6px', color: '#1a1a2e' }}>
|
||||
{selectedNode.node.displayName || selectedNode.node.username}
|
||||
</div>
|
||||
<div style={{ fontSize: '10px', color: '#666', marginBottom: '8px' }}>
|
||||
@{selectedNode.node.username}
|
||||
</div>
|
||||
|
||||
{/* Connection actions */}
|
||||
{selectedNode.node.trustLevelTo ? (
|
||||
// Already connected - show trust level options
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
// Toggle trust level
|
||||
const newLevel = selectedNode.node.trustLevelTo === 'trusted' ? 'connected' : 'trusted';
|
||||
setIsConnecting(true);
|
||||
// This would need updateTrustLevel function passed as prop
|
||||
// For now, just close the popup
|
||||
setSelectedNode(null);
|
||||
setIsConnecting(false);
|
||||
}}
|
||||
disabled={isConnecting}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: selectedNode.node.trustLevelTo === 'trusted' ? '#fef3c7' : '#d1fae5',
|
||||
color: selectedNode.node.trustLevelTo === 'trusted' ? '#92400e' : '#065f46',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{selectedNode.node.trustLevelTo === 'trusted' ? 'Downgrade to Connected' : 'Upgrade to Trusted'}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
// Find connection ID and disconnect
|
||||
const edge = edges.find(e =>
|
||||
(e.source === currentUserId && e.target === selectedNode.node.id) ||
|
||||
(e.target === currentUserId && e.source === selectedNode.node.id)
|
||||
);
|
||||
if (edge && onDisconnect) {
|
||||
await onDisconnect(edge.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to disconnect:', err);
|
||||
}
|
||||
setSelectedNode(null);
|
||||
setIsConnecting(false);
|
||||
}}
|
||||
disabled={isConnecting}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: '#fee2e2',
|
||||
color: '#dc2626',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isConnecting ? 'Removing...' : 'Remove Connection'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Not connected - show connect options
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
// Use username for API call (CryptID username), not tldraw session id
|
||||
const userId = selectedNode.node.username || selectedNode.node.id;
|
||||
await onConnect(userId, 'connected');
|
||||
} catch (err) {
|
||||
console.error('Failed to connect:', err);
|
||||
}
|
||||
setSelectedNode(null);
|
||||
setIsConnecting(false);
|
||||
}}
|
||||
disabled={isConnecting}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: '#fef3c7',
|
||||
color: '#92400e',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect (View)'}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
// Use username for API call (CryptID username), not tldraw session id
|
||||
// Connect with trusted level directly
|
||||
const userId = selectedNode.node.username || selectedNode.node.id;
|
||||
await onConnect(userId, 'trusted');
|
||||
} catch (err) {
|
||||
console.error('Failed to connect:', err);
|
||||
}
|
||||
setSelectedNode(null);
|
||||
setIsConnecting(false);
|
||||
}}
|
||||
disabled={isConnecting}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: '10px',
|
||||
backgroundColor: '#d1fae5',
|
||||
color: '#065f46',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Trust (Edit)'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Connect option - only for non-anonymous users */}
|
||||
{!selectedNode.node.isAnonymous && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const userId = selectedNode.node.username || selectedNode.node.id;
|
||||
await onConnect(userId, 'connected');
|
||||
} catch (err) {
|
||||
console.error('Failed to connect:', err);
|
||||
}
|
||||
setSelectedNode(null);
|
||||
setIsConnecting(false);
|
||||
}}
|
||||
disabled={isConnecting}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '11px',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDarkMode ? '#fbbf24' : '#92400e',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<span>🔗</span> Connect with {selectedNode.node.displayName || selectedNode.node.username}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedNode(null)}
|
||||
style={{
|
||||
marginTop: '6px',
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
fontSize: '9px',
|
||||
backgroundColor: 'transparent',
|
||||
color: '#666',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Navigate option - only for in-room users */}
|
||||
{selectedNode.node.isInRoom && onGoToUser && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onGoToUser(selectedNode.node);
|
||||
setSelectedNode(null);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '11px',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDarkMode ? '#a0a0ff' : '#4f46e5',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<span>📍</span> Navigate to {selectedNode.node.displayName || selectedNode.node.username}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div style={styles.stats}>
|
||||
<div style={styles.stat} title="Users in this room">
|
||||
<div style={{ ...styles.statDot, backgroundColor: '#4f46e5' }} />
|
||||
<span>{inRoomCount}</span>
|
||||
</div>
|
||||
<div style={styles.stat} title="Trusted (edit access)">
|
||||
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.trusted }} />
|
||||
<span>{trustedCount}</span>
|
||||
</div>
|
||||
<div style={styles.stat} title="Connected (view access)">
|
||||
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.connected }} />
|
||||
<span>{connectedCount}</span>
|
||||
</div>
|
||||
<div style={styles.stat} title="Unconnected">
|
||||
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.unconnected, border: '1px solid #e5e7eb' }} />
|
||||
<span>{unconnectedCount}</span>
|
||||
</div>
|
||||
{anonymousCount > 0 && (
|
||||
<div style={styles.stat} title="Anonymous">
|
||||
<div style={{ ...styles.statDot, backgroundColor: TRUST_LEVEL_COLORS.anonymous }} />
|
||||
<span>{anonymousCount}</span>
|
||||
{/* Screenfollow option - only for in-room users */}
|
||||
{selectedNode.node.isInRoom && onFollowUser && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onFollowUser(selectedNode.node);
|
||||
setSelectedNode(null);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '11px',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDarkMode ? '#60a5fa' : '#2563eb',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<span>👁️</span> Screenfollow {selectedNode.node.displayName || selectedNode.node.username}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Open profile option - only for non-anonymous users */}
|
||||
{!selectedNode.node.isAnonymous && onOpenProfile && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onOpenProfile(selectedNode.node);
|
||||
setSelectedNode(null);
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '11px',
|
||||
backgroundColor: 'transparent',
|
||||
color: isDarkMode ? '#e0e0e0' : '#374151',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.05)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<span>👤</span> Open {selectedNode.node.displayName || selectedNode.node.username}'s profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -69,10 +69,10 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
|||
},
|
||||
];
|
||||
|
||||
// Add collaborators
|
||||
// Add collaborators - TLInstancePresence has userId and userName
|
||||
collaborators.forEach((c: any) => {
|
||||
participants.push({
|
||||
id: c.id || c.userId || c.instanceId,
|
||||
id: c.userId || c.id,
|
||||
username: c.userName || 'Anonymous',
|
||||
color: c.color,
|
||||
});
|
||||
|
|
@ -112,6 +112,58 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
|||
console.log('Node clicked:', node);
|
||||
}, []);
|
||||
|
||||
// Handle going to a user's cursor on canvas (navigate/pan to their location)
|
||||
const handleGoToUser = useCallback((node: any) => {
|
||||
if (!editor) return;
|
||||
|
||||
// Find the collaborator's cursor position
|
||||
// TLInstancePresence has userId and userName properties
|
||||
const targetCollaborator = collaborators.find((c: any) =>
|
||||
c.id === node.id ||
|
||||
c.userId === node.id ||
|
||||
c.userName === node.username
|
||||
);
|
||||
|
||||
if (targetCollaborator && targetCollaborator.cursor) {
|
||||
// Pan to the user's cursor position
|
||||
const { x, y } = targetCollaborator.cursor;
|
||||
editor.centerOnPoint({ x, y });
|
||||
} else {
|
||||
// If no cursor position, try to find any presence data
|
||||
console.log('Could not find cursor position for user:', node.username);
|
||||
}
|
||||
}, [editor, collaborators]);
|
||||
|
||||
// Handle screen following a user (camera follows their view)
|
||||
const handleFollowUser = useCallback((node: any) => {
|
||||
if (!editor) return;
|
||||
|
||||
// Find the collaborator to follow
|
||||
// TLInstancePresence has userId and userName properties
|
||||
const targetCollaborator = collaborators.find((c: any) =>
|
||||
c.id === node.id ||
|
||||
c.userId === node.id ||
|
||||
c.userName === node.username
|
||||
);
|
||||
|
||||
if (targetCollaborator) {
|
||||
// Use tldraw's built-in follow functionality - needs userId
|
||||
const userId = targetCollaborator.userId || targetCollaborator.id;
|
||||
editor.startFollowingUser(userId);
|
||||
console.log('Now following user:', node.username);
|
||||
} else {
|
||||
console.log('Could not find user to follow:', node.username);
|
||||
}
|
||||
}, [editor, collaborators]);
|
||||
|
||||
// Handle opening a user's profile
|
||||
const handleOpenProfile = useCallback((node: any) => {
|
||||
// Open user profile in a new tab or modal
|
||||
const username = node.username || node.id;
|
||||
// Navigate to user profile page
|
||||
window.open(`/profile/${username}`, '_blank');
|
||||
}, []);
|
||||
|
||||
// Handle edge click
|
||||
const handleEdgeClick = useCallback((edge: GraphEdge) => {
|
||||
setSelectedEdge(edge);
|
||||
|
|
@ -156,6 +208,9 @@ export function NetworkGraphPanel({ onExpand }: NetworkGraphPanelProps) {
|
|||
onConnect={handleConnect}
|
||||
onDisconnect={handleDisconnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
onGoToUser={handleGoToUser}
|
||||
onFollowUser={handleFollowUser}
|
||||
onOpenProfile={handleOpenProfile}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onExpandClick={handleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
|
|
|
|||
|
|
@ -155,6 +155,45 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
|
|||
try {
|
||||
setState(prev => ({ ...prev, isLoading: !prev.nodes.length }));
|
||||
|
||||
// Double-check authentication before making API calls
|
||||
// This handles race conditions where session state might not be updated yet
|
||||
const currentUserId = (() => {
|
||||
try {
|
||||
// Session is stored as 'canvas_auth_session' by sessionPersistence.ts
|
||||
const sessionStr = localStorage.getItem('canvas_auth_session');
|
||||
if (sessionStr) {
|
||||
const s = JSON.parse(sessionStr);
|
||||
if (s.authed && s.username) return s.username;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (!currentUserId) {
|
||||
// Not authenticated - use room participants only
|
||||
const anonymousNodes: GraphNode[] = roomParticipants.map(participant => ({
|
||||
id: participant.id,
|
||||
username: participant.username,
|
||||
displayName: participant.username,
|
||||
avatarColor: participant.color,
|
||||
isInRoom: true,
|
||||
roomPresenceColor: participant.color,
|
||||
isCurrentUser: participant.id === roomParticipants[0]?.id,
|
||||
isAnonymous: true,
|
||||
trustLevelTo: undefined,
|
||||
trustLevelFrom: undefined,
|
||||
}));
|
||||
|
||||
setState({
|
||||
nodes: anonymousNodes,
|
||||
edges: [],
|
||||
myConnections: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch graph, optionally scoped to room
|
||||
let graph: NetworkGraph;
|
||||
try {
|
||||
|
|
@ -165,7 +204,10 @@ export function useNetworkGraph(options: UseNetworkGraphOptions = {}): UseNetwor
|
|||
}
|
||||
} catch (apiError: any) {
|
||||
// If API call fails (e.g., 401 Unauthorized), fall back to showing room participants
|
||||
console.warn('Network graph API failed, falling back to room participants:', apiError.message);
|
||||
// Only log if it's not a 401 (which is expected for auth issues)
|
||||
if (!apiError.message?.includes('401')) {
|
||||
console.warn('Network graph API failed, falling back to room participants:', apiError.message);
|
||||
}
|
||||
const fallbackNodes: GraphNode[] = roomParticipants.map(participant => ({
|
||||
id: participant.id,
|
||||
username: participant.username,
|
||||
|
|
|
|||
|
|
@ -105,7 +105,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
const result = await AuthService.login(username);
|
||||
|
||||
if (result.success && result.session) {
|
||||
setSessionState(result.session);
|
||||
// IMPORTANT: Clear permission cache when auth state changes
|
||||
// This forces a fresh permission fetch with the new credentials
|
||||
setSessionState({
|
||||
...result.session,
|
||||
boardPermissions: {},
|
||||
currentBoardPermission: undefined,
|
||||
});
|
||||
console.log('🔐 Login successful - cleared permission cache');
|
||||
|
||||
// Save session to localStorage if authenticated
|
||||
if (result.session.authed && result.session.username) {
|
||||
|
|
@ -141,7 +148,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
const result = await AuthService.register(username);
|
||||
|
||||
if (result.success && result.session) {
|
||||
setSessionState(result.session);
|
||||
// IMPORTANT: Clear permission cache when auth state changes
|
||||
// This forces a fresh permission fetch with the new credentials
|
||||
setSessionState({
|
||||
...result.session,
|
||||
boardPermissions: {},
|
||||
currentBoardPermission: undefined,
|
||||
});
|
||||
console.log('🔐 Registration successful - cleared permission cache');
|
||||
|
||||
// Save session to localStorage if authenticated
|
||||
if (result.session.authed && result.session.username) {
|
||||
|
|
@ -178,7 +192,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
loading: false,
|
||||
backupCreated: null,
|
||||
obsidianVaultPath: undefined,
|
||||
obsidianVaultName: undefined
|
||||
obsidianVaultName: undefined,
|
||||
// IMPORTANT: Clear permission cache on logout to force fresh fetch on next login
|
||||
boardPermissions: {},
|
||||
currentBoardPermission: undefined,
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
|
@ -215,6 +232,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
const fetchBoardPermission = useCallback(async (boardId: string): Promise<PermissionLevel> => {
|
||||
// Check cache first (but only if no access token - token changes permissions)
|
||||
if (!accessToken && session.boardPermissions?.[boardId]) {
|
||||
console.log('🔐 Using cached permission for board:', boardId, session.boardPermissions[boardId]);
|
||||
return session.boardPermissions[boardId];
|
||||
}
|
||||
|
||||
|
|
@ -224,13 +242,24 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
let publicKeyUsed: string | null = null;
|
||||
if (session.authed && session.username) {
|
||||
const publicKey = crypto.getPublicKey(session.username);
|
||||
if (publicKey) {
|
||||
headers['X-CryptID-PublicKey'] = publicKey;
|
||||
publicKeyUsed = publicKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Debug: Log what we're sending
|
||||
console.log('🔐 fetchBoardPermission:', {
|
||||
boardId,
|
||||
sessionAuthed: session.authed,
|
||||
sessionUsername: session.username,
|
||||
publicKeyUsed: publicKeyUsed ? `${publicKeyUsed.substring(0, 20)}...` : null,
|
||||
hasAccessToken: !!accessToken
|
||||
});
|
||||
|
||||
// Build URL with optional access token
|
||||
let url = `${WORKER_URL}/boards/${boardId}/permission`;
|
||||
if (accessToken) {
|
||||
|
|
@ -245,8 +274,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch board permission:', response.status);
|
||||
// Default to 'view' for unauthenticated (secure by default)
|
||||
return 'view';
|
||||
// Default to 'edit' for authenticated users, 'view' for unauthenticated
|
||||
const defaultPermission: PermissionLevel = session.authed ? 'edit' : 'view';
|
||||
console.log('🔐 Using default permission (API failed):', defaultPermission);
|
||||
return defaultPermission;
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
|
|
@ -254,27 +285,47 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||
isOwner: boolean;
|
||||
boardExists: boolean;
|
||||
grantedByToken?: boolean;
|
||||
isExplicitPermission?: boolean; // Whether this permission was explicitly set
|
||||
};
|
||||
|
||||
// Debug: Log what we received
|
||||
console.log('🔐 Permission response:', data);
|
||||
|
||||
if (data.grantedByToken) {
|
||||
console.log('🔓 Permission granted via access token:', data.permission);
|
||||
}
|
||||
|
||||
// Determine effective permission
|
||||
// If authenticated user and board doesn't have explicit permissions set,
|
||||
// default to 'edit' instead of 'view'
|
||||
let effectivePermission = data.permission;
|
||||
|
||||
if (session.authed && data.permission === 'view') {
|
||||
// If board doesn't exist in permission system or permission isn't explicitly set,
|
||||
// authenticated users should get edit access by default
|
||||
if (!data.boardExists || data.isExplicitPermission === false) {
|
||||
effectivePermission = 'edit';
|
||||
console.log('🔓 Upgrading to edit: authenticated user with no explicit view restriction');
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the permission
|
||||
setSessionState(prev => ({
|
||||
...prev,
|
||||
currentBoardPermission: data.permission,
|
||||
currentBoardPermission: effectivePermission,
|
||||
boardPermissions: {
|
||||
...prev.boardPermissions,
|
||||
[boardId]: data.permission,
|
||||
[boardId]: effectivePermission,
|
||||
},
|
||||
}));
|
||||
|
||||
return data.permission;
|
||||
return effectivePermission;
|
||||
} catch (error) {
|
||||
console.error('Error fetching board permission:', error);
|
||||
// Default to 'view' (secure by default)
|
||||
return 'view';
|
||||
// Default to 'edit' for authenticated users, 'view' for unauthenticated
|
||||
const defaultPermission: PermissionLevel = session.authed ? 'edit' : 'view';
|
||||
console.log('🔐 Using default permission (error):', defaultPermission);
|
||||
return defaultPermission;
|
||||
}
|
||||
}, [session.authed, session.username, session.boardPermissions, accessToken]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,33 +1,32 @@
|
|||
/* Anonymous Viewer Banner Styles */
|
||||
/* Anonymous Viewer Banner Styles - Compact unified sign-in box (~33% smaller) */
|
||||
|
||||
.anonymous-viewer-banner {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10000;
|
||||
top: 56px;
|
||||
right: 10px;
|
||||
z-index: 100000;
|
||||
|
||||
max-width: 600px;
|
||||
width: calc(100% - 40px);
|
||||
max-width: 200px;
|
||||
width: auto;
|
||||
|
||||
background: linear-gradient(135deg, #1e1e2e 0%, #2d2d44 100%);
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.3),
|
||||
0 0 40px rgba(139, 92, 246, 0.15);
|
||||
0 4px 16px rgba(0, 0, 0, 0.2),
|
||||
0 0 10px rgba(139, 92, 246, 0.08);
|
||||
|
||||
animation: slideUp 0.4s ease-out;
|
||||
animation: slideDown 0.25s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -35,22 +34,29 @@
|
|||
background: linear-gradient(135deg, #2d1f3d 0%, #3d2d54 100%);
|
||||
border-color: rgba(236, 72, 153, 0.4);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.3),
|
||||
0 0 40px rgba(236, 72, 153, 0.2);
|
||||
0 8px 24px rgba(0, 0, 0, 0.25),
|
||||
0 0 20px rgba(236, 72, 153, 0.15);
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.banner-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -58,6 +64,11 @@
|
|||
color: white;
|
||||
}
|
||||
|
||||
.banner-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.edit-triggered .banner-icon {
|
||||
background: linear-gradient(135deg, #ec4899 0%, #a855f7 100%);
|
||||
}
|
||||
|
|
@ -68,10 +79,10 @@
|
|||
}
|
||||
|
||||
.banner-headline {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
margin: 0 0 2px 0;
|
||||
font-size: 11px;
|
||||
color: #f0f0f0;
|
||||
line-height: 1.4;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.banner-headline strong {
|
||||
|
|
@ -80,75 +91,27 @@
|
|||
|
||||
.banner-summary {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-size: 10px;
|
||||
color: #a0a0b0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.banner-details {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.banner-details p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: #c0c0d0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.banner-details strong {
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.cryptid-benefits {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.cryptid-benefits li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #a0a0b0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cryptid-benefits li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.benefit-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cryptid-benefits a {
|
||||
color: #8b5cf6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cryptid-benefits a:hover {
|
||||
text-decoration: underline;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.banner-signup-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
padding: 5px 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
|
@ -156,7 +119,7 @@
|
|||
|
||||
.banner-signup-btn:hover {
|
||||
background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%);
|
||||
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.4);
|
||||
box-shadow: 0 2px 12px rgba(139, 92, 246, 0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
|
|
@ -166,55 +129,47 @@
|
|||
|
||||
.edit-triggered .banner-signup-btn:hover {
|
||||
background: linear-gradient(135deg, #db2777 0%, #9333ea 100%);
|
||||
box-shadow: 0 4px 20px rgba(236, 72, 153, 0.4);
|
||||
box-shadow: 0 2px 12px rgba(236, 72, 153, 0.35);
|
||||
}
|
||||
|
||||
.banner-dismiss-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
color: #808090;
|
||||
background: transparent;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.banner-dismiss-btn svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.banner-dismiss-btn:hover {
|
||||
color: #f0f0f0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.banner-expand-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: #8b5cf6;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-expand-btn:hover {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.banner-edit-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
gap: 4px;
|
||||
padding: 5px 10px;
|
||||
background: rgba(236, 72, 153, 0.1);
|
||||
border-top: 1px solid rgba(236, 72, 153, 0.2);
|
||||
border-radius: 0 0 16px 16px;
|
||||
font-size: 13px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
font-size: 9px;
|
||||
color: #f472b6;
|
||||
}
|
||||
|
||||
|
|
@ -264,8 +219,8 @@
|
|||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-color: rgba(139, 92, 246, 0.2);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.1),
|
||||
0 0 40px rgba(139, 92, 246, 0.1);
|
||||
0 8px 24px rgba(0, 0, 0, 0.08),
|
||||
0 0 16px rgba(139, 92, 246, 0.08);
|
||||
}
|
||||
|
||||
.banner-headline {
|
||||
|
|
@ -276,48 +231,48 @@
|
|||
color: #1e1e2e;
|
||||
}
|
||||
|
||||
.banner-summary,
|
||||
.banner-details p,
|
||||
.cryptid-benefits li {
|
||||
.banner-summary {
|
||||
color: #606080;
|
||||
}
|
||||
|
||||
.banner-dismiss-btn {
|
||||
color: #606080;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.banner-dismiss-btn:hover {
|
||||
color: #2d2d44;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.anonymous-viewer-banner {
|
||||
bottom: 10px;
|
||||
max-width: none;
|
||||
width: calc(100% - 20px);
|
||||
border-radius: 12px;
|
||||
top: 56px;
|
||||
right: 8px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
.banner-icon svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.banner-signup-btn {
|
||||
flex: 1;
|
||||
.banner-headline {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.banner-summary {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import { loadSession, saveSession, clearStoredSession } from './sessionPersisten
|
|||
export class AuthService {
|
||||
/**
|
||||
* Initialize the authentication state
|
||||
*
|
||||
* IMPORTANT: Having crypto keys stored on device does NOT mean the user is logged in.
|
||||
* Keys persist after logout for potential re-authentication. Only the session's
|
||||
* `authed` flag determines if a user is currently authenticated.
|
||||
*/
|
||||
static async initialize(): Promise<{
|
||||
session: Session;
|
||||
|
|
@ -13,8 +17,12 @@ export class AuthService {
|
|||
const storedSession = loadSession();
|
||||
let session: Session;
|
||||
|
||||
if (storedSession && storedSession.authed && storedSession.username) {
|
||||
// Restore existing session
|
||||
// Only restore session if ALL conditions are met:
|
||||
// 1. Session exists in storage
|
||||
// 2. Session has authed=true
|
||||
// 3. Session has a username
|
||||
if (storedSession && storedSession.authed === true && storedSession.username) {
|
||||
// Restore existing authenticated session
|
||||
session = {
|
||||
username: storedSession.username,
|
||||
authed: true,
|
||||
|
|
@ -23,14 +31,18 @@ export class AuthService {
|
|||
obsidianVaultPath: storedSession.obsidianVaultPath,
|
||||
obsidianVaultName: storedSession.obsidianVaultName
|
||||
};
|
||||
console.log('🔐 Restored authenticated session for:', storedSession.username);
|
||||
} else {
|
||||
// No stored session
|
||||
// No valid session - user is anonymous
|
||||
// Note: User may still have crypto keys stored from previous sessions,
|
||||
// but that doesn't mean they're logged in
|
||||
session = {
|
||||
username: '',
|
||||
authed: false,
|
||||
loading: false,
|
||||
backupCreated: null
|
||||
};
|
||||
console.log('🔐 No valid session found - user is anonymous');
|
||||
}
|
||||
|
||||
return { session };
|
||||
|
|
|
|||
|
|
@ -42,18 +42,25 @@ export const saveSession = (session: Session): boolean => {
|
|||
*/
|
||||
export const loadSession = (): StoredSession | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
console.log('🔐 loadSession: No stored session found');
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const parsed = JSON.parse(stored) as StoredSession;
|
||||
|
||||
console.log('🔐 loadSession: Found stored session:', {
|
||||
username: parsed.username,
|
||||
authed: parsed.authed,
|
||||
timestamp: new Date(parsed.timestamp).toISOString()
|
||||
});
|
||||
|
||||
// Check if session is not too old (7 days)
|
||||
const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
|
||||
if (Date.now() - parsed.timestamp > maxAge) {
|
||||
console.log('🔐 loadSession: Session expired, removing');
|
||||
localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/ /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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.'
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
@ -35,7 +35,8 @@ const API_BASE = '/api/networking';
|
|||
*/
|
||||
function getCurrentUserId(): string | null {
|
||||
try {
|
||||
const sessionStr = localStorage.getItem('cryptid_session');
|
||||
// Session is stored as 'canvas_auth_session' by sessionPersistence.ts
|
||||
const sessionStr = localStorage.getItem('canvas_auth_session');
|
||||
if (sessionStr) {
|
||||
const session = JSON.parse(sessionStr);
|
||||
if (session.authed && session.username) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { EmbedShape } from "@/shapes/EmbedShapeUtil"
|
|||
import { EmbedTool } from "@/tools/EmbedTool"
|
||||
import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
|
||||
import { MarkdownTool } from "@/tools/MarkdownTool"
|
||||
import { defaultShapeUtils, defaultBindingUtils } from "tldraw"
|
||||
import { defaultShapeUtils, defaultBindingUtils, defaultShapeTools } from "tldraw"
|
||||
import { components } from "@/ui/components"
|
||||
import { overrides } from "@/ui/overrides"
|
||||
import { unfurlBookmarkUrl } from "../utils/unfurlBookmarkUrl"
|
||||
|
|
@ -71,8 +71,8 @@ import { GraphLayoutCollection } from "@/graph/GraphLayoutCollection"
|
|||
import { GestureTool } from "@/GestureTool"
|
||||
import { CmdK } from "@/CmdK"
|
||||
import { setupMultiPasteHandler } from "@/utils/multiPasteHandler"
|
||||
import { ConnectionStatusIndicator } from "@/components/ConnectionStatusIndicator"
|
||||
import AnonymousViewerBanner from "@/components/auth/AnonymousViewerBanner"
|
||||
import { ConnectionProvider } from "@/context/ConnectionContext"
|
||||
import { PermissionLevel } from "@/lib/auth/types"
|
||||
import "@/css/anonymous-banner.css"
|
||||
|
||||
|
|
@ -281,7 +281,28 @@ export function Board() {
|
|||
const [permissionLoading, setPermissionLoading] = useState(true)
|
||||
const [showEditPrompt, setShowEditPrompt] = useState(false)
|
||||
|
||||
// Fetch permission when board loads
|
||||
// Track previous auth state to detect transitions (fixes React timing issue)
|
||||
// Effects run AFTER render, but we need to know if auth JUST changed during this render
|
||||
const prevAuthRef = useRef(session.authed)
|
||||
const authJustChanged = prevAuthRef.current !== session.authed
|
||||
|
||||
// Counter to force Tldraw remount on every auth change
|
||||
// This guarantees a fresh tldraw instance with correct read-only state
|
||||
const [authChangeCount, setAuthChangeCount] = useState(0)
|
||||
|
||||
// Reset permission state when auth changes (ensures fresh fetch on login/logout)
|
||||
useEffect(() => {
|
||||
// Update the ref after render
|
||||
prevAuthRef.current = session.authed
|
||||
// Increment counter to force tldraw remount
|
||||
setAuthChangeCount(c => c + 1)
|
||||
// When auth state changes, reset permission to trigger fresh fetch
|
||||
setPermission(null)
|
||||
setPermissionLoading(true)
|
||||
console.log('🔄 Auth changed, forcing tldraw remount. New auth state:', session.authed)
|
||||
}, [session.authed])
|
||||
|
||||
// Fetch permission when board loads or auth changes
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
|
|
@ -291,6 +312,7 @@ export function Board() {
|
|||
const perm = await fetchBoardPermission(roomId)
|
||||
if (mounted) {
|
||||
setPermission(perm)
|
||||
console.log('🔐 Permission fetched:', perm)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch permission:', error)
|
||||
|
|
@ -312,8 +334,46 @@ export function Board() {
|
|||
}
|
||||
}, [roomId, fetchBoardPermission, session.authed])
|
||||
|
||||
// Check if user can edit (either has edit/admin permission, or is authenticated with default edit access)
|
||||
const isReadOnly = permission === 'view' || (!session.authed && !permission)
|
||||
// Check if user can edit
|
||||
// Authenticated users get edit access by default unless explicitly restricted to 'view'
|
||||
// Unauthenticated users are always read-only
|
||||
// Note: permission will be 'edit' for authenticated users by default (see AuthContext)
|
||||
//
|
||||
// CRITICAL: Don't restrict in these cases:
|
||||
// 1. Auth is loading
|
||||
// 2. Auth just changed (React effects haven't run yet, permission state is stale)
|
||||
// 3. Permission is loading for authenticated users
|
||||
// This prevents authenticated users from briefly seeing read-only mode which hides
|
||||
// default tools (only tools with readonlyOk: true show in read-only mode)
|
||||
const isReadOnly = (
|
||||
session.loading ||
|
||||
(session.authed && authJustChanged) || // Auth just changed, permission is stale
|
||||
(session.authed && permissionLoading)
|
||||
)
|
||||
? false // Don't restrict while loading/transitioning - assume authenticated users can edit
|
||||
: (!session.authed || permission === 'view')
|
||||
|
||||
// Debug logging for permission issues
|
||||
console.log('🔐 Permission Debug:', {
|
||||
permission,
|
||||
permissionLoading,
|
||||
sessionAuthed: session.authed,
|
||||
sessionLoading: session.loading,
|
||||
sessionUsername: session.username,
|
||||
authJustChanged,
|
||||
isReadOnly,
|
||||
reason: session.loading
|
||||
? 'auth loading - allowing edit temporarily'
|
||||
: (session.authed && authJustChanged)
|
||||
? 'auth just changed - allowing edit until effects run'
|
||||
: (session.authed && permissionLoading)
|
||||
? 'permission loading for authenticated user - allowing edit temporarily'
|
||||
: !session.authed
|
||||
? 'not authenticated - view only mode'
|
||||
: permission === 'view'
|
||||
? 'explicitly restricted to view-only by board admin'
|
||||
: 'authenticated with edit access'
|
||||
})
|
||||
|
||||
// Handler for when user tries to edit in read-only mode
|
||||
const handleEditAttempt = () => {
|
||||
|
|
@ -916,17 +976,39 @@ export function Board() {
|
|||
|
||||
let lastContentHash = '';
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let idleCallbackId: number | null = null;
|
||||
|
||||
const captureScreenshot = async () => {
|
||||
// Don't capture if user is actively drawing (pointer is down)
|
||||
// This prevents interrupting continuous drawing operations
|
||||
const inputs = editor.inputs;
|
||||
if (inputs.isPointing || inputs.isDragging) {
|
||||
// Reschedule for later when user stops drawing
|
||||
timeoutId = setTimeout(captureScreenshot, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentShapes = editor.getCurrentPageShapes();
|
||||
const currentContentHash = currentShapes.length > 0
|
||||
const currentContentHash = currentShapes.length > 0
|
||||
? currentShapes.map(shape => `${shape.id}-${shape.type}`).sort().join('|')
|
||||
: '';
|
||||
|
||||
// Only capture if content actually changed
|
||||
if (currentContentHash !== lastContentHash) {
|
||||
lastContentHash = currentContentHash;
|
||||
await captureBoardScreenshot(editor, roomId);
|
||||
|
||||
// Use requestIdleCallback to run during browser idle time
|
||||
// This prevents blocking the main thread during user interactions
|
||||
const doCapture = () => {
|
||||
captureBoardScreenshot(editor, roomId);
|
||||
};
|
||||
|
||||
if ('requestIdleCallback' in window) {
|
||||
idleCallbackId = requestIdleCallback(doCapture, { timeout: 5000 });
|
||||
} else {
|
||||
// Fallback for browsers without requestIdleCallback
|
||||
setTimeout(doCapture, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -934,14 +1016,18 @@ export function Board() {
|
|||
const unsubscribe = store.store.listen(() => {
|
||||
// Clear existing timeout
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
// Set new timeout for debounced screenshot capture
|
||||
timeoutId = setTimeout(captureScreenshot, 3000);
|
||||
|
||||
// Set new timeout for debounced screenshot capture (5 seconds instead of 3)
|
||||
// Longer debounce gives users more time for continuous operations
|
||||
timeoutId = setTimeout(captureScreenshot, 5000);
|
||||
}, { source: "user", scope: "document" });
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (idleCallbackId !== null && 'cancelIdleCallback' in window) {
|
||||
cancelIdleCallback(idleCallbackId);
|
||||
}
|
||||
};
|
||||
}, [editor, roomId, store.store]);
|
||||
|
||||
|
|
@ -1071,143 +1157,149 @@ export function Board() {
|
|||
|
||||
return (
|
||||
<AutomergeHandleProvider handle={automergeHandle}>
|
||||
<div style={{ position: "fixed", inset: 0 }}>
|
||||
<Tldraw
|
||||
store={store.store}
|
||||
user={user}
|
||||
shapeUtils={[...defaultShapeUtils, ...customShapeUtils]}
|
||||
tools={customTools}
|
||||
components={components}
|
||||
overrides={{
|
||||
...overrides,
|
||||
actions: (editor, actions, helpers) => {
|
||||
const customActions = overrides.actions?.(editor, actions, helpers) ?? {}
|
||||
return {
|
||||
...actions,
|
||||
...customActions,
|
||||
<ConnectionProvider connectionState={connectionState} isNetworkOnline={isNetworkOnline}>
|
||||
<div style={{ position: "fixed", inset: 0 }}>
|
||||
<Tldraw
|
||||
key={`tldraw-${authChangeCount}-${session.authed ? 'authed' : 'anon'}-${session.username || 'guest'}`}
|
||||
store={store.store}
|
||||
user={user}
|
||||
shapeUtils={[...defaultShapeUtils, ...customShapeUtils]}
|
||||
tools={[...defaultShapeTools, ...customTools]}
|
||||
components={components}
|
||||
overrides={{
|
||||
...overrides,
|
||||
actions: (editor, actions, helpers) => {
|
||||
const customActions = overrides.actions?.(editor, actions, helpers) ?? {}
|
||||
return {
|
||||
...actions,
|
||||
...customActions,
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
cameraOptions={{
|
||||
zoomSteps: [
|
||||
0.001, // Min zoom
|
||||
0.0025,
|
||||
0.005,
|
||||
0.01,
|
||||
0.025,
|
||||
0.05,
|
||||
0.1,
|
||||
0.25,
|
||||
0.5,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
8,
|
||||
16,
|
||||
32,
|
||||
64, // Max zoom
|
||||
],
|
||||
}}
|
||||
onMount={(editor) => {
|
||||
setEditor(editor)
|
||||
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
|
||||
editor.setCurrentTool("hand")
|
||||
setInitialCameraFromUrl(editor)
|
||||
handleInitialPageLoad(editor)
|
||||
registerPropagators(editor, [
|
||||
TickPropagator,
|
||||
ChangePropagator,
|
||||
ClickPropagator,
|
||||
])
|
||||
}}
|
||||
cameraOptions={{
|
||||
zoomSteps: [
|
||||
0.001, // Min zoom
|
||||
0.0025,
|
||||
0.005,
|
||||
0.01,
|
||||
0.025,
|
||||
0.05,
|
||||
0.1,
|
||||
0.25,
|
||||
0.5,
|
||||
1,
|
||||
2,
|
||||
4,
|
||||
8,
|
||||
16,
|
||||
32,
|
||||
64, // Max zoom
|
||||
],
|
||||
}}
|
||||
onMount={(editor) => {
|
||||
setEditor(editor)
|
||||
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
|
||||
editor.setCurrentTool("hand")
|
||||
setInitialCameraFromUrl(editor)
|
||||
handleInitialPageLoad(editor)
|
||||
registerPropagators(editor, [
|
||||
TickPropagator,
|
||||
ChangePropagator,
|
||||
ClickPropagator,
|
||||
])
|
||||
|
||||
// Clean up corrupted shapes that cause "No nearest point found" errors
|
||||
// This typically happens with draw/line shapes that have no points
|
||||
try {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const corruptedShapeIds: TLShapeId[] = []
|
||||
// Clean up corrupted shapes that cause "No nearest point found" errors
|
||||
// This typically happens with draw/line shapes that have no points
|
||||
try {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const corruptedShapeIds: TLShapeId[] = []
|
||||
|
||||
for (const shape of allShapes) {
|
||||
// Check draw and line shapes for missing/empty segments
|
||||
if (shape.type === 'draw' || shape.type === 'line') {
|
||||
const props = shape.props as any
|
||||
// Draw shapes need segments with points
|
||||
if (shape.type === 'draw') {
|
||||
if (!props.segments || props.segments.length === 0) {
|
||||
corruptedShapeIds.push(shape.id)
|
||||
continue
|
||||
for (const shape of allShapes) {
|
||||
// Check draw and line shapes for missing/empty segments
|
||||
if (shape.type === 'draw' || shape.type === 'line') {
|
||||
const props = shape.props as any
|
||||
// Draw shapes need segments with points
|
||||
if (shape.type === 'draw') {
|
||||
if (!props.segments || props.segments.length === 0) {
|
||||
corruptedShapeIds.push(shape.id)
|
||||
continue
|
||||
}
|
||||
// Check if all segments have no points
|
||||
const hasPoints = props.segments.some((seg: any) => seg.points && seg.points.length > 0)
|
||||
if (!hasPoints) {
|
||||
corruptedShapeIds.push(shape.id)
|
||||
}
|
||||
}
|
||||
// Check if all segments have no points
|
||||
const hasPoints = props.segments.some((seg: any) => seg.points && seg.points.length > 0)
|
||||
if (!hasPoints) {
|
||||
corruptedShapeIds.push(shape.id)
|
||||
}
|
||||
}
|
||||
// Line shapes need points
|
||||
if (shape.type === 'line') {
|
||||
if (!props.points || Object.keys(props.points).length === 0) {
|
||||
corruptedShapeIds.push(shape.id)
|
||||
// Line shapes need points
|
||||
if (shape.type === 'line') {
|
||||
if (!props.points || Object.keys(props.points).length === 0) {
|
||||
corruptedShapeIds.push(shape.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (corruptedShapeIds.length > 0) {
|
||||
console.warn(`🧹 Removing ${corruptedShapeIds.length} corrupted shapes (draw/line with no points)`)
|
||||
editor.deleteShapes(corruptedShapeIds)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up corrupted shapes:', error)
|
||||
}
|
||||
|
||||
if (corruptedShapeIds.length > 0) {
|
||||
console.warn(`🧹 Removing ${corruptedShapeIds.length} corrupted shapes (draw/line with no points)`)
|
||||
editor.deleteShapes(corruptedShapeIds)
|
||||
// Set user preferences immediately if user is authenticated
|
||||
if (session.authed && session.username) {
|
||||
try {
|
||||
editor.user.updateUserPreferences({
|
||||
id: session.username,
|
||||
name: session.username,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting initial TLDraw user preferences:', error);
|
||||
}
|
||||
} else {
|
||||
// Set default user preferences when not authenticated
|
||||
try {
|
||||
editor.user.updateUserPreferences({
|
||||
id: 'user-1',
|
||||
name: 'User 1',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting default TLDraw user preferences:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cleaning up corrupted shapes:', error)
|
||||
}
|
||||
|
||||
// Set user preferences immediately if user is authenticated
|
||||
if (session.authed && session.username) {
|
||||
try {
|
||||
editor.user.updateUserPreferences({
|
||||
id: session.username,
|
||||
name: session.username,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting initial TLDraw user preferences:', error);
|
||||
}
|
||||
} else {
|
||||
// Set default user preferences when not authenticated
|
||||
try {
|
||||
editor.user.updateUserPreferences({
|
||||
id: 'user-1',
|
||||
name: 'User 1',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting default TLDraw user preferences:', error);
|
||||
}
|
||||
}
|
||||
initializeGlobalCollections(editor, collections)
|
||||
// Note: User presence is configured through the useAutomergeSync hook above
|
||||
// The authenticated username should appear in the people section
|
||||
// MycelialIntelligence is now a permanent UI bar - no shape creation needed
|
||||
initializeGlobalCollections(editor, collections)
|
||||
// Note: User presence is configured through the useAutomergeSync hook above
|
||||
// The authenticated username should appear in the people section
|
||||
// MycelialIntelligence is now a permanent UI bar - no shape creation needed
|
||||
|
||||
// Set read-only mode based on permission
|
||||
if (isReadOnly) {
|
||||
editor.updateInstanceState({ isReadonly: true })
|
||||
console.log('🔒 Board is in read-only mode for this user')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CmdK />
|
||||
<PrivateWorkspaceManager />
|
||||
<VisibilityChangeManager />
|
||||
</Tldraw>
|
||||
<ConnectionStatusIndicator
|
||||
connectionState={connectionState}
|
||||
isNetworkOnline={isNetworkOnline}
|
||||
/>
|
||||
{/* Anonymous viewer banner - show for unauthenticated users or when edit was attempted */}
|
||||
{(!session.authed || showEditPrompt) && (
|
||||
<AnonymousViewerBanner
|
||||
onAuthenticated={handleAuthenticated}
|
||||
triggeredByEdit={showEditPrompt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
// Set read-only mode based on auth state
|
||||
// IMPORTANT: Use session.authed directly here, not the isReadOnly variable
|
||||
// The isReadOnly variable might have stale values due to React's timing (effects run after render)
|
||||
// For authenticated users, we assume editable until permission proves otherwise
|
||||
// The effect that watches isReadOnly will update this if user only has 'view' permission
|
||||
const initialReadOnly = !session.authed
|
||||
editor.updateInstanceState({ isReadonly: initialReadOnly })
|
||||
console.log('🔄 onMount: session.authed =', session.authed, ', setting isReadonly =', initialReadOnly)
|
||||
console.log(initialReadOnly
|
||||
? '🔒 Board is in read-only mode (not authenticated)'
|
||||
: '🔓 Board is editable (authenticated)')
|
||||
}}
|
||||
>
|
||||
<CmdK />
|
||||
<PrivateWorkspaceManager />
|
||||
<VisibilityChangeManager />
|
||||
</Tldraw>
|
||||
{/* Anonymous viewer banner - show for unauthenticated users or when edit was attempted */}
|
||||
{/* Wait for auth to finish loading to avoid flash, then show if not authed or edit triggered */}
|
||||
{!session.loading && (!session.authed || showEditPrompt) && (
|
||||
<AnonymousViewerBanner
|
||||
onAuthenticated={handleAuthenticated}
|
||||
triggeredByEdit={showEditPrompt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ConnectionProvider>
|
||||
</AutomergeHandleProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,9 +6,12 @@ import {
|
|||
DefaultMainMenuContent,
|
||||
useEditor,
|
||||
} from "tldraw";
|
||||
import { useState } from "react";
|
||||
import { MiroImportDialog } from "@/components/MiroImportDialog";
|
||||
|
||||
export function CustomMainMenu() {
|
||||
const editor = useEditor()
|
||||
const [showMiroImport, setShowMiroImport] = useState(false)
|
||||
|
||||
const importJSON = (editor: Editor) => {
|
||||
const input = document.createElement("input");
|
||||
|
|
@ -727,29 +730,42 @@ export function CustomMainMenu() {
|
|||
};
|
||||
|
||||
return (
|
||||
<DefaultMainMenu>
|
||||
<DefaultMainMenuContent />
|
||||
<TldrawUiMenuItem
|
||||
id="export"
|
||||
label="Export JSON"
|
||||
icon="external-link"
|
||||
readonlyOk
|
||||
onSelect={() => exportJSON(editor)}
|
||||
<>
|
||||
<DefaultMainMenu>
|
||||
<DefaultMainMenuContent />
|
||||
<TldrawUiMenuItem
|
||||
id="export"
|
||||
label="Export JSON"
|
||||
icon="external-link"
|
||||
readonlyOk
|
||||
onSelect={() => exportJSON(editor)}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="import"
|
||||
label="Import JSON"
|
||||
icon="external-link"
|
||||
readonlyOk
|
||||
onSelect={() => importJSON(editor)}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="import-miro"
|
||||
label="Import from Miro"
|
||||
icon="external-link"
|
||||
readonlyOk
|
||||
onSelect={() => setShowMiroImport(true)}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="fit-to-content"
|
||||
label="Fit to Content"
|
||||
icon="external-link"
|
||||
readonlyOk
|
||||
onSelect={() => fitToContent(editor)}
|
||||
/>
|
||||
</DefaultMainMenu>
|
||||
<MiroImportDialog
|
||||
isOpen={showMiroImport}
|
||||
onClose={() => setShowMiroImport(false)}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="import"
|
||||
label="Import JSON"
|
||||
icon="external-link"
|
||||
readonlyOk
|
||||
onSelect={() => importJSON(editor)}
|
||||
/>
|
||||
<TldrawUiMenuItem
|
||||
id="fit-to-content"
|
||||
label="Fit to Content"
|
||||
icon="external-link"
|
||||
readonlyOk
|
||||
onSelect={() => fitToContent(editor)}
|
||||
/>
|
||||
</DefaultMainMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -642,150 +642,156 @@ export function CustomToolbar() {
|
|||
|
||||
if (!isReady) return null
|
||||
|
||||
// Only show custom tools for authenticated users
|
||||
const isAuthenticated = session.authed
|
||||
|
||||
return (
|
||||
<>
|
||||
<DefaultToolbar>
|
||||
<DefaultToolbarContent />
|
||||
{tools["VideoChat"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["VideoChat"]}
|
||||
icon="video"
|
||||
label="Video Chat"
|
||||
isSelected={tools["VideoChat"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
{/* Custom tools - only shown when authenticated */}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
{tools["VideoChat"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["VideoChat"]}
|
||||
icon="video"
|
||||
label="Video Chat"
|
||||
isSelected={tools["VideoChat"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["ChatBox"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["ChatBox"]}
|
||||
icon="chat"
|
||||
label="Chat"
|
||||
isSelected={tools["ChatBox"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Embed"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Embed"]}
|
||||
icon="embed"
|
||||
label="Embed"
|
||||
isSelected={tools["Embed"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["SlideShape"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["SlideShape"]}
|
||||
icon="slides"
|
||||
label="Slide"
|
||||
isSelected={tools["SlideShape"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Markdown"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Markdown"]}
|
||||
icon="markdown"
|
||||
label="Markdown"
|
||||
isSelected={tools["Markdown"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["MycrozineTemplate"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["MycrozineTemplate"]}
|
||||
icon="mycrozinetemplate"
|
||||
label="MycrozineTemplate"
|
||||
isSelected={
|
||||
tools["MycrozineTemplate"].id === editor.getCurrentToolId()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tools["Prompt"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Prompt"]}
|
||||
icon="prompt"
|
||||
label="LLM Prompt"
|
||||
isSelected={tools["Prompt"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["ObsidianNote"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["ObsidianNote"]}
|
||||
icon="file-text"
|
||||
label="Obsidian Note"
|
||||
isSelected={tools["ObsidianNote"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Transcription"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Transcription"]}
|
||||
icon="microphone"
|
||||
label="Transcription"
|
||||
isSelected={tools["Transcription"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Holon"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Holon"]}
|
||||
icon="globe"
|
||||
label="Holon"
|
||||
isSelected={tools["Holon"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["FathomMeetings"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["FathomMeetings"]}
|
||||
icon="calendar"
|
||||
label="Fathom Meetings"
|
||||
isSelected={tools["FathomMeetings"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["ImageGen"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["ImageGen"]}
|
||||
icon="image"
|
||||
label="Image Generation"
|
||||
isSelected={tools["ImageGen"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["VideoGen"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["VideoGen"]}
|
||||
icon="video"
|
||||
label="Video Generation"
|
||||
isSelected={tools["VideoGen"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Multmux"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Multmux"]}
|
||||
icon="terminal"
|
||||
label="Terminal"
|
||||
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Map"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Map"]}
|
||||
icon="geo-globe"
|
||||
label="Map"
|
||||
isSelected={tools["Map"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{/* Refresh All ObsNotes Button */}
|
||||
{(() => {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const obsNoteShapes = allShapes.filter(shape => shape.type === 'ObsNote')
|
||||
return obsNoteShapes.length > 0 && (
|
||||
<TldrawUiMenuItem
|
||||
id="refresh-all-obsnotes"
|
||||
icon="refresh-cw"
|
||||
label="Refresh All Notes"
|
||||
onSelect={() => {
|
||||
const event = new CustomEvent('refresh-all-obsnotes')
|
||||
window.dispatchEvent(event)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
{tools["ChatBox"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["ChatBox"]}
|
||||
icon="chat"
|
||||
label="Chat"
|
||||
isSelected={tools["ChatBox"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Embed"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Embed"]}
|
||||
icon="embed"
|
||||
label="Embed"
|
||||
isSelected={tools["Embed"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["SlideShape"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["SlideShape"]}
|
||||
icon="slides"
|
||||
label="Slide"
|
||||
isSelected={tools["SlideShape"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Markdown"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Markdown"]}
|
||||
icon="markdown"
|
||||
label="Markdown"
|
||||
isSelected={tools["Markdown"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["MycrozineTemplate"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["MycrozineTemplate"]}
|
||||
icon="mycrozinetemplate"
|
||||
label="MycrozineTemplate"
|
||||
isSelected={
|
||||
tools["MycrozineTemplate"].id === editor.getCurrentToolId()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tools["Prompt"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Prompt"]}
|
||||
icon="prompt"
|
||||
label="LLM Prompt"
|
||||
isSelected={tools["Prompt"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["ObsidianNote"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["ObsidianNote"]}
|
||||
icon="file-text"
|
||||
label="Obsidian Note"
|
||||
isSelected={tools["ObsidianNote"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Transcription"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Transcription"]}
|
||||
icon="microphone"
|
||||
label="Transcription"
|
||||
isSelected={tools["Transcription"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Holon"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Holon"]}
|
||||
icon="globe"
|
||||
label="Holon"
|
||||
isSelected={tools["Holon"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["FathomMeetings"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["FathomMeetings"]}
|
||||
icon="calendar"
|
||||
label="Fathom Meetings"
|
||||
isSelected={tools["FathomMeetings"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["ImageGen"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["ImageGen"]}
|
||||
icon="image"
|
||||
label="Image Generation"
|
||||
isSelected={tools["ImageGen"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["VideoGen"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["VideoGen"]}
|
||||
icon="video"
|
||||
label="Video Generation"
|
||||
isSelected={tools["VideoGen"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Multmux"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Multmux"]}
|
||||
icon="terminal"
|
||||
label="Terminal"
|
||||
isSelected={tools["Multmux"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{tools["Map"] && (
|
||||
<TldrawUiMenuItem
|
||||
{...tools["Map"]}
|
||||
icon="geo-globe"
|
||||
label="Map"
|
||||
isSelected={tools["Map"].id === editor.getCurrentToolId()}
|
||||
/>
|
||||
)}
|
||||
{/* MycelialIntelligence moved to permanent floating bar */}
|
||||
{/* Share Location tool removed for now */}
|
||||
{/* Refresh All ObsNotes Button */}
|
||||
{(() => {
|
||||
const allShapes = editor.getCurrentPageShapes()
|
||||
const obsNoteShapes = allShapes.filter(shape => shape.type === 'ObsNote')
|
||||
return obsNoteShapes.length > 0 && (
|
||||
<TldrawUiMenuItem
|
||||
id="refresh-all-obsnotes"
|
||||
icon="refresh-cw"
|
||||
label="Refresh All Notes"
|
||||
onSelect={() => {
|
||||
const event = new CustomEvent('refresh-all-obsnotes')
|
||||
window.dispatchEvent(event)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</DefaultToolbar>
|
||||
|
||||
{/* Fathom Meetings Panel */}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useWebSpeechTranscription } from "@/hooks/useWebSpeechTranscription"
|
|||
import { ToolSchema } from "@/lib/toolSchema"
|
||||
import { spawnTools, spawnTool } from "@/utils/toolSpawner"
|
||||
import { TransformCommand } from "@/utils/selectionTransforms"
|
||||
import { useConnectionStatus } from "@/context/ConnectionContext"
|
||||
|
||||
// Copy icon component
|
||||
const CopyIcon = () => (
|
||||
|
|
@ -803,9 +804,92 @@ interface ConversationMessage {
|
|||
executedTransform?: TransformCommand
|
||||
}
|
||||
|
||||
// Connection status indicator component - unobtrusive inline display
|
||||
interface ConnectionStatusProps {
|
||||
connectionState: 'disconnected' | 'connecting' | 'connected' | 'reconnecting'
|
||||
isNetworkOnline: boolean
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function ConnectionStatusBadge({ connectionState, isNetworkOnline, isDark }: ConnectionStatusProps) {
|
||||
// Don't show anything when fully connected and online
|
||||
if (connectionState === 'connected' && isNetworkOnline) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getStatusConfig = () => {
|
||||
if (!isNetworkOnline) {
|
||||
return {
|
||||
icon: '📴',
|
||||
label: 'Offline',
|
||||
color: isDark ? '#a78bfa' : '#8b5cf6',
|
||||
pulse: false,
|
||||
}
|
||||
}
|
||||
|
||||
switch (connectionState) {
|
||||
case 'connecting':
|
||||
return {
|
||||
icon: '🌱',
|
||||
label: 'Connecting',
|
||||
color: '#f59e0b',
|
||||
pulse: true,
|
||||
}
|
||||
case 'reconnecting':
|
||||
return {
|
||||
icon: '🔄',
|
||||
label: 'Reconnecting',
|
||||
color: '#f59e0b',
|
||||
pulse: true,
|
||||
}
|
||||
case 'disconnected':
|
||||
return {
|
||||
icon: '🍄',
|
||||
label: 'Local',
|
||||
color: isDark ? '#a78bfa' : '#8b5cf6',
|
||||
pulse: false,
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const config = getStatusConfig()
|
||||
if (!config) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
backgroundColor: isDark ? 'rgba(139, 92, 246, 0.15)' : 'rgba(139, 92, 246, 0.1)',
|
||||
border: `1px solid ${isDark ? 'rgba(139, 92, 246, 0.3)' : 'rgba(139, 92, 246, 0.2)'}`,
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
color: config.color,
|
||||
animation: config.pulse ? 'connectionPulse 2s infinite' : undefined,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={!isNetworkOnline
|
||||
? 'Working offline - changes saved locally and will sync when reconnected'
|
||||
: connectionState === 'reconnecting'
|
||||
? 'Reconnecting to server - your changes are safe'
|
||||
: 'Connecting to server...'
|
||||
}
|
||||
>
|
||||
<span style={{ fontSize: '11px' }}>{config.icon}</span>
|
||||
<span>{config.label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MycelialIntelligenceBar() {
|
||||
const editor = useEditor()
|
||||
const isDark = useDarkMode()
|
||||
const { connectionState, isNetworkOnline } = useConnectionStatus()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -838,8 +922,11 @@ export function MycelialIntelligenceBar() {
|
|||
const hasTldrawDialog = document.querySelector('[data-state="open"][role="dialog"]') !== null
|
||||
const hasAuthModal = document.querySelector('.auth-modal-overlay') !== null
|
||||
const hasPopup = document.querySelector('.profile-popup') !== null
|
||||
const hasCryptIDModal = document.querySelector('.cryptid-modal-overlay') !== null
|
||||
const hasMiroModal = document.querySelector('.miro-modal-overlay') !== null
|
||||
const hasObsidianModal = document.querySelector('.obsidian-browser') !== null
|
||||
|
||||
setIsModalOpen(hasSettingsModal || hasTldrawDialog || hasAuthModal || hasPopup)
|
||||
setIsModalOpen(hasSettingsModal || hasTldrawDialog || hasAuthModal || hasPopup || hasCryptIDModal || hasMiroModal || hasObsidianModal)
|
||||
}
|
||||
|
||||
// Initial check
|
||||
|
|
@ -1351,8 +1438,9 @@ export function MycelialIntelligenceBar() {
|
|||
}, [])
|
||||
|
||||
// Height: taller when showing suggestion chips (single tool or 2+ selected)
|
||||
// Base height matches the top-right menu (~40px) for visual alignment
|
||||
const showSuggestions = selectedToolInfo || (selectionInfo && selectionInfo.count > 1)
|
||||
const collapsedHeight = showSuggestions ? 76 : 48
|
||||
const collapsedHeight = showSuggestions ? 68 : 40
|
||||
const maxExpandedHeight = isMobile ? 300 : 400
|
||||
// Responsive width: full width on mobile, percentage on narrow, fixed on desktop
|
||||
const barWidth = isMobile ? 'calc(100% - 20px)' : isNarrow ? 'calc(100% - 120px)' : 520
|
||||
|
|
@ -1414,8 +1502,8 @@ export function MycelialIntelligenceBar() {
|
|||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
padding: '6px 10px 6px 14px',
|
||||
gap: '2px',
|
||||
padding: '4px 8px 4px 12px',
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
|
|
@ -1431,7 +1519,7 @@ export function MycelialIntelligenceBar() {
|
|||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '16px',
|
||||
fontSize: '14px',
|
||||
opacity: 0.9,
|
||||
}}>
|
||||
🍄🧠
|
||||
|
|
@ -1487,13 +1575,20 @@ export function MycelialIntelligenceBar() {
|
|||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '8px 4px',
|
||||
fontSize: '14px',
|
||||
padding: '6px 4px',
|
||||
fontSize: '13px',
|
||||
color: colors.inputText,
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Connection status indicator - unobtrusive */}
|
||||
<ConnectionStatusBadge
|
||||
connectionState={connectionState}
|
||||
isNetworkOnline={isNetworkOnline}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* Indexing indicator */}
|
||||
{isIndexing && (
|
||||
<span style={{
|
||||
|
|
@ -1515,8 +1610,8 @@ export function MycelialIntelligenceBar() {
|
|||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: isRecording
|
||||
|
|
@ -1549,9 +1644,9 @@ export function MycelialIntelligenceBar() {
|
|||
onPointerDown={(e) => e.stopPropagation()}
|
||||
disabled={!prompt.trim() || isLoading}
|
||||
style={{
|
||||
height: '34px',
|
||||
padding: selectedToolInfo ? '0 12px' : '0 14px',
|
||||
borderRadius: '17px',
|
||||
height: '28px',
|
||||
padding: selectedToolInfo ? '0 10px' : '0 12px',
|
||||
borderRadius: '14px',
|
||||
border: 'none',
|
||||
background: prompt.trim() && !isLoading
|
||||
? selectedToolInfo ? '#6366f1' : ACCENT_COLOR
|
||||
|
|
@ -1589,8 +1684,8 @@ export function MycelialIntelligenceBar() {
|
|||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
|
|
@ -1687,6 +1782,12 @@ export function MycelialIntelligenceBar() {
|
|||
}}>
|
||||
<span style={{ fontStyle: 'italic', opacity: 0.85 }}>ask your mycelial intelligence anything about this workspace</span>
|
||||
</span>
|
||||
{/* Connection status in expanded header */}
|
||||
<ConnectionStatusBadge
|
||||
connectionState={connectionState}
|
||||
isNetworkOnline={isNetworkOnline}
|
||||
isDark={isDark}
|
||||
/>
|
||||
{isIndexing && (
|
||||
<span style={{
|
||||
color: colors.textMuted,
|
||||
|
|
@ -2087,6 +2188,10 @@ export function MycelialIntelligenceBar() {
|
|||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
@keyframes connectionPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
import React from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { CustomMainMenu } from "./CustomMainMenu"
|
||||
import { CustomToolbar } from "./CustomToolbar"
|
||||
import { CustomContextMenu } from "./CustomContextMenu"
|
||||
import { FocusLockIndicator } from "./FocusLockIndicator"
|
||||
import { MycelialIntelligenceBar } from "./MycelialIntelligenceBar"
|
||||
import { CommandPalette } from "./CommandPalette"
|
||||
import { UserSettingsModal } from "./UserSettingsModal"
|
||||
import { NetworkGraphPanel } from "../components/networking"
|
||||
import CryptIDDropdown from "../components/auth/CryptIDDropdown"
|
||||
import StarBoardButton from "../components/StarBoardButton"
|
||||
import ShareBoardButton from "../components/ShareBoardButton"
|
||||
import { SettingsDialog } from "./SettingsDialog"
|
||||
import { useAuth } from "../context/AuthContext"
|
||||
import { PermissionLevel } from "../lib/auth/types"
|
||||
import { WORKER_URL } from "../constants/workerUrl"
|
||||
import {
|
||||
DefaultKeyboardShortcutsDialog,
|
||||
DefaultKeyboardShortcutsDialogContent,
|
||||
|
|
@ -32,16 +36,103 @@ const AI_TOOLS = [
|
|||
{ id: 'mycelial', name: 'Mycelial', icon: '🍄', model: 'llama3.1:70b', provider: 'Ollama', type: 'local' },
|
||||
];
|
||||
|
||||
// Permission labels and colors
|
||||
const PERMISSION_CONFIG: Record<PermissionLevel, { label: string; color: string; icon: string }> = {
|
||||
view: { label: 'View Only', color: '#6b7280', icon: '👁️' },
|
||||
edit: { label: 'Edit', color: '#3b82f6', icon: '✏️' },
|
||||
admin: { label: 'Admin', color: '#10b981', icon: '👑' },
|
||||
}
|
||||
|
||||
// Custom SharePanel with layout: CryptID -> Star -> Gear -> Question mark
|
||||
function CustomSharePanel() {
|
||||
const tools = useTools()
|
||||
const actions = useActions()
|
||||
const { addDialog, removeDialog } = useDialogs()
|
||||
const { session } = useAuth()
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const boardId = slug || 'mycofi33'
|
||||
|
||||
const [showShortcuts, setShowShortcuts] = React.useState(false)
|
||||
const [showSettings, setShowSettings] = React.useState(false)
|
||||
const [showSettingsDropdown, setShowSettingsDropdown] = React.useState(false)
|
||||
const [showAISection, setShowAISection] = React.useState(false)
|
||||
const [hasApiKey, setHasApiKey] = React.useState(false)
|
||||
const [permissionRequestStatus, setPermissionRequestStatus] = React.useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
|
||||
const [requestMessage, setRequestMessage] = React.useState('')
|
||||
|
||||
// Refs for dropdown positioning
|
||||
const settingsButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||
const shortcutsButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||
const [settingsDropdownPos, setSettingsDropdownPos] = React.useState<{ top: number; right: number } | null>(null)
|
||||
const [shortcutsDropdownPos, setShortcutsDropdownPos] = React.useState<{ top: number; right: number } | null>(null)
|
||||
|
||||
// Get current permission from session
|
||||
// Authenticated users default to 'edit', unauthenticated to 'view'
|
||||
const currentPermission: PermissionLevel = session.currentBoardPermission || (session.authed ? 'edit' : 'view')
|
||||
|
||||
// Request permission upgrade
|
||||
const handleRequestPermission = async (requestedLevel: PermissionLevel) => {
|
||||
if (!session.authed || !session.username) {
|
||||
setRequestMessage('Please sign in to request permissions')
|
||||
return
|
||||
}
|
||||
|
||||
setPermissionRequestStatus('sending')
|
||||
try {
|
||||
const response = await fetch(`${WORKER_URL}/boards/${boardId}/permission-request`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: session.username,
|
||||
email: session.email,
|
||||
requestedPermission: requestedLevel,
|
||||
currentPermission,
|
||||
boardId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setPermissionRequestStatus('sent')
|
||||
setRequestMessage(`Request for ${PERMISSION_CONFIG[requestedLevel].label} access sent to board admins`)
|
||||
setTimeout(() => {
|
||||
setPermissionRequestStatus('idle')
|
||||
setRequestMessage('')
|
||||
}, 5000)
|
||||
} else {
|
||||
throw new Error('Failed to send request')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Permission request error:', error)
|
||||
setPermissionRequestStatus('error')
|
||||
setRequestMessage('Failed to send request. Please try again.')
|
||||
setTimeout(() => {
|
||||
setPermissionRequestStatus('idle')
|
||||
setRequestMessage('')
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Update dropdown positions when they open
|
||||
React.useEffect(() => {
|
||||
if (showSettingsDropdown && settingsButtonRef.current) {
|
||||
const rect = settingsButtonRef.current.getBoundingClientRect()
|
||||
setSettingsDropdownPos({
|
||||
top: rect.bottom + 8,
|
||||
right: window.innerWidth - rect.right,
|
||||
})
|
||||
}
|
||||
}, [showSettingsDropdown])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showShortcuts && shortcutsButtonRef.current) {
|
||||
const rect = shortcutsButtonRef.current.getBoundingClientRect()
|
||||
setShortcutsDropdownPos({
|
||||
top: rect.bottom + 8,
|
||||
right: window.innerWidth - rect.right,
|
||||
})
|
||||
}
|
||||
}, [showShortcuts])
|
||||
|
||||
// ESC key handler for closing dropdowns
|
||||
React.useEffect(() => {
|
||||
|
|
@ -236,8 +327,9 @@ function CustomSharePanel() {
|
|||
<Separator />
|
||||
|
||||
{/* Settings gear button with dropdown */}
|
||||
<div style={{ position: 'relative', padding: '0 2px' }}>
|
||||
<div style={{ padding: '0 2px' }}>
|
||||
<button
|
||||
ref={settingsButtonRef}
|
||||
onClick={() => setShowSettingsDropdown(!showSettingsDropdown)}
|
||||
className="share-panel-btn"
|
||||
style={{
|
||||
|
|
@ -272,8 +364,8 @@ function CustomSharePanel() {
|
|||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Settings dropdown */}
|
||||
{showSettingsDropdown && (
|
||||
{/* Settings dropdown - rendered via portal to break out of parent container */}
|
||||
{showSettingsDropdown && settingsDropdownPos && createPortal(
|
||||
<>
|
||||
{/* Backdrop - only uses onClick, not onPointerDown */}
|
||||
<div
|
||||
|
|
@ -288,9 +380,9 @@ function CustomSharePanel() {
|
|||
{/* Dropdown menu */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 8px)',
|
||||
right: 0,
|
||||
position: 'fixed',
|
||||
top: settingsDropdownPos.top,
|
||||
right: settingsDropdownPos.right,
|
||||
minWidth: '200px',
|
||||
maxHeight: '60vh',
|
||||
overflowY: 'auto',
|
||||
|
|
@ -306,6 +398,121 @@ function CustomSharePanel() {
|
|||
onWheel={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Board Permission Display */}
|
||||
<div style={{ padding: '10px 16px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '10px', fontSize: '13px', color: 'var(--color-text)' }}>
|
||||
<span style={{ fontSize: '16px' }}>🔐</span>
|
||||
<span>Board Permission</span>
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
background: `${PERMISSION_CONFIG[currentPermission].color}20`,
|
||||
color: PERMISSION_CONFIG[currentPermission].color,
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{PERMISSION_CONFIG[currentPermission].icon} {PERMISSION_CONFIG[currentPermission].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Permission levels with request buttons */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginTop: '8px' }}>
|
||||
{(['view', 'edit', 'admin'] as PermissionLevel[]).map((level) => {
|
||||
const config = PERMISSION_CONFIG[level]
|
||||
const isCurrent = currentPermission === level
|
||||
const canRequest = session.authed && !isCurrent && (
|
||||
(level === 'edit' && currentPermission === 'view') ||
|
||||
(level === 'admin' && currentPermission !== 'admin')
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={level}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '6px 8px',
|
||||
borderRadius: '6px',
|
||||
background: isCurrent ? `${config.color}15` : 'transparent',
|
||||
border: isCurrent ? `1px solid ${config.color}40` : '1px solid transparent',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '12px',
|
||||
color: isCurrent ? config.color : 'var(--color-text-3)',
|
||||
fontWeight: isCurrent ? 600 : 400,
|
||||
}}>
|
||||
<span>{config.icon}</span>
|
||||
<span>{config.label}</span>
|
||||
{isCurrent && <span style={{ fontSize: '10px', opacity: 0.7 }}>(current)</span>}
|
||||
</span>
|
||||
|
||||
{canRequest && (
|
||||
<button
|
||||
onClick={() => handleRequestPermission(level)}
|
||||
disabled={permissionRequestStatus === 'sending'}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
background: config.color,
|
||||
color: 'white',
|
||||
cursor: permissionRequestStatus === 'sending' ? 'wait' : 'pointer',
|
||||
opacity: permissionRequestStatus === 'sending' ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{permissionRequestStatus === 'sending' ? '...' : 'Request'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Request status message */}
|
||||
{requestMessage && (
|
||||
<p style={{
|
||||
margin: '8px 0 0',
|
||||
fontSize: '11px',
|
||||
padding: '6px 8px',
|
||||
borderRadius: '4px',
|
||||
background: permissionRequestStatus === 'sent' ? '#d1fae5' :
|
||||
permissionRequestStatus === 'error' ? '#fee2e2' : 'var(--color-muted-2)',
|
||||
color: permissionRequestStatus === 'sent' ? '#065f46' :
|
||||
permissionRequestStatus === 'error' ? '#dc2626' : 'var(--color-text-3)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{requestMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!session.authed && (
|
||||
<p style={{
|
||||
margin: '8px 0 0',
|
||||
fontSize: '10px',
|
||||
color: 'var(--color-text-3)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
Sign in to request higher permissions
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '4px 0' }} />
|
||||
|
||||
{/* Dark mode toggle */}
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
@ -444,42 +651,9 @@ function CustomSharePanel() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ height: '1px', background: 'var(--color-panel-contrast)', margin: '4px 0' }} />
|
||||
|
||||
{/* All settings */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSettingsDropdown(false)
|
||||
setShowSettings(true)
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 16px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--color-text)',
|
||||
fontSize: '13px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--color-muted-2)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'none'
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
<span>All Settings...</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -488,6 +662,7 @@ function CustomSharePanel() {
|
|||
{/* Help/Keyboard shortcuts button - rightmost */}
|
||||
<div style={{ padding: '0 4px' }}>
|
||||
<button
|
||||
ref={shortcutsButtonRef}
|
||||
onClick={() => setShowShortcuts(!showShortcuts)}
|
||||
className="share-panel-btn"
|
||||
style={{
|
||||
|
|
@ -525,8 +700,8 @@ function CustomSharePanel() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard shortcuts panel */}
|
||||
{showShortcuts && (
|
||||
{/* Keyboard shortcuts panel - rendered via portal to break out of parent container */}
|
||||
{showShortcuts && shortcutsDropdownPos && createPortal(
|
||||
<>
|
||||
{/* Backdrop - only uses onClick, not onPointerDown */}
|
||||
<div
|
||||
|
|
@ -541,11 +716,11 @@ function CustomSharePanel() {
|
|||
{/* Shortcuts menu */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 8px)',
|
||||
right: 0,
|
||||
position: 'fixed',
|
||||
top: shortcutsDropdownPos.top,
|
||||
right: shortcutsDropdownPos.right,
|
||||
width: '320px',
|
||||
maxHeight: '60vh',
|
||||
maxHeight: '50vh',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
background: 'var(--color-panel)',
|
||||
|
|
@ -553,7 +728,7 @@ function CustomSharePanel() {
|
|||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
|
||||
zIndex: 99999,
|
||||
padding: '12px 0',
|
||||
padding: '10px 0',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
|
|
@ -629,17 +804,10 @@ function CustomSharePanel() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Settings Modal */}
|
||||
{showSettings && (
|
||||
<UserSettingsModal
|
||||
onClose={() => setShowSettings(false)}
|
||||
isDarkMode={isDarkMode}
|
||||
onToggleDarkMode={handleToggleDarkMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ export async function handleGetPermission(
|
|||
const db = env.CRYPTID_DB;
|
||||
if (!db) {
|
||||
// No database - default to view for anonymous (secure by default)
|
||||
console.log('🔐 Permission check: No database configured');
|
||||
return new Response(JSON.stringify({
|
||||
permission: 'view',
|
||||
isOwner: false,
|
||||
|
|
@ -168,6 +169,10 @@ export async function handleGetPermission(
|
|||
let userId: string | null = null;
|
||||
const publicKey = request.headers.get('X-CryptID-PublicKey');
|
||||
|
||||
console.log('🔐 Permission check for board:', boardId, {
|
||||
publicKeyReceived: publicKey ? `${publicKey.substring(0, 20)}...` : null
|
||||
});
|
||||
|
||||
if (publicKey) {
|
||||
const deviceKey = await db.prepare(
|
||||
'SELECT user_id FROM device_keys WHERE public_key = ?'
|
||||
|
|
@ -175,6 +180,9 @@ export async function handleGetPermission(
|
|||
|
||||
if (deviceKey) {
|
||||
userId = deviceKey.user_id;
|
||||
console.log('🔐 Found user ID for public key:', userId);
|
||||
} else {
|
||||
console.log('🔐 No user found for public key');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,6 +191,7 @@ export async function handleGetPermission(
|
|||
const accessToken = url.searchParams.get('token');
|
||||
|
||||
const result = await getEffectivePermission(db, boardId, userId, accessToken);
|
||||
console.log('🔐 Permission result:', result);
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
|
@ -293,6 +302,10 @@ export async function handleListPermissions(
|
|||
* POST /boards/:boardId/permissions
|
||||
* Grant permission to a user (admin only)
|
||||
* Body: { userId, permission, username? }
|
||||
*
|
||||
* Note: This endpoint allows granting 'admin' permission because it requires
|
||||
* specifying a user by ID or CryptID username. This is the ONLY way to grant
|
||||
* admin access - share links (access tokens) can only grant 'view' or 'edit'.
|
||||
*/
|
||||
export async function handleGrantPermission(
|
||||
boardId: string,
|
||||
|
|
@ -709,8 +722,12 @@ export async function handleCreateAccessToken(
|
|||
maxUses?: number;
|
||||
};
|
||||
|
||||
if (!body.permission || !['view', 'edit', 'admin'].includes(body.permission)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid permission level' }), {
|
||||
// Only allow 'view' and 'edit' permissions for access tokens
|
||||
// Admin permission must be granted directly by username/email through handleGrantPermission
|
||||
if (!body.permission || !['view', 'edit'].includes(body.permission)) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid permission level. Share links can only grant view or edit access. Use direct permission grants for admin access.'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
email_verified INTEGER DEFAULT 0,
|
||||
cryptid_username TEXT NOT NULL,
|
||||
cryptid_username TEXT UNIQUE NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
|
@ -89,12 +89,13 @@ CREATE INDEX IF NOT EXISTS idx_board_perms_user ON board_permissions(user_id);
|
|||
CREATE INDEX IF NOT EXISTS idx_board_perms_board_user ON board_permissions(board_id, user_id);
|
||||
|
||||
-- Access tokens for shareable links with specific permission levels
|
||||
-- Anonymous users can use these tokens to get edit/admin access without authentication
|
||||
-- Anonymous users can use these tokens to get view/edit access without authentication
|
||||
-- Note: Admin permission cannot be shared via token - must be granted directly by username/email
|
||||
CREATE TABLE IF NOT EXISTS board_access_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
board_id TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE, -- Random hex token (64 chars)
|
||||
permission TEXT NOT NULL CHECK (permission IN ('view', 'edit', 'admin')),
|
||||
permission TEXT NOT NULL CHECK (permission IN ('view', 'edit')),
|
||||
created_by TEXT NOT NULL, -- User ID who created the token
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
expires_at TEXT, -- NULL = never expires
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export interface Environment {
|
|||
RESEND_API_KEY?: string;
|
||||
CRYPTID_EMAIL_FROM?: string;
|
||||
APP_URL?: string;
|
||||
// Admin secret for protected endpoints
|
||||
ADMIN_SECRET?: string;
|
||||
}
|
||||
|
||||
// CryptID types for auth
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ import {
|
|||
} from "./boardPermissions"
|
||||
import {
|
||||
handleSendBackupEmail,
|
||||
handleSearchUsers,
|
||||
handleListAllUsers,
|
||||
handleCheckUsername,
|
||||
} from "./cryptidAuth"
|
||||
|
||||
// make sure our sync durable objects are made available to cloudflare
|
||||
|
|
@ -128,6 +131,7 @@ const { preflight, corsify } = cors({
|
|||
"X-CryptID-PublicKey", // CryptID authentication header
|
||||
"X-User-Id", // User ID header for networking API
|
||||
"X-Api-Key", // API key header for external services
|
||||
"X-Admin-Secret", // Admin secret header for protected endpoints
|
||||
"*"
|
||||
],
|
||||
maxAge: 86400,
|
||||
|
|
@ -832,13 +836,71 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
|
|||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Miro Import API
|
||||
// =============================================================================
|
||||
|
||||
// Proxy endpoint for fetching external images (needed for CORS-blocked Miro images)
|
||||
.get("/proxy", async (req) => {
|
||||
const url = new URL(req.url)
|
||||
const targetUrl = url.searchParams.get('url')
|
||||
|
||||
if (!targetUrl) {
|
||||
return new Response(JSON.stringify({ error: 'Missing url parameter' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; TldrawImporter/1.0)',
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return new Response(JSON.stringify({ error: `Failed to fetch: ${response.statusText}` }), {
|
||||
status: response.status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// Get content type and pass through the image
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream'
|
||||
const body = await response.arrayBuffer()
|
||||
|
||||
return new Response(body, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Proxy fetch error:', error)
|
||||
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// CryptID Auth API
|
||||
// =============================================================================
|
||||
|
||||
// Check if a username is available for registration
|
||||
.get("/api/auth/check-username", handleCheckUsername)
|
||||
|
||||
// Send backup email for multi-device setup
|
||||
.post("/api/auth/send-backup-email", handleSendBackupEmail)
|
||||
|
||||
// Search for CryptID users by username (for granting permissions)
|
||||
.get("/api/auth/users/search", handleSearchUsers)
|
||||
|
||||
// List all CryptID users (admin only, requires X-Admin-Secret header)
|
||||
.get("/admin/users", handleListAllUsers)
|
||||
|
||||
// =============================================================================
|
||||
// User Networking / Social Graph API
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue