import React, { useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { useParams } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { WORKER_URL } from '../constants/workerUrl'; import * as crypto from '../lib/auth/crypto'; interface BoardSettingsDropdownProps { className?: string; } interface BoardInfo { id: string; name: string | null; isProtected: boolean; ownerUsername: string | null; } interface Editor { userId: string; username: string; email: string; permission: string; grantedAt: string; } const BoardSettingsDropdown: React.FC = ({ className = '' }) => { const { slug } = useParams<{ slug: string }>(); const { session } = useAuth(); const [showDropdown, setShowDropdown] = useState(false); const [boardInfo, setBoardInfo] = useState(null); const [editors, setEditors] = useState([]); const [isAdmin, setIsAdmin] = useState(false); const [isGlobalAdmin, setIsGlobalAdmin] = useState(false); const [loading, setLoading] = useState(false); const [updating, setUpdating] = useState(false); const [requestingAdmin, setRequestingAdmin] = useState(false); const [adminRequestSent, setAdminRequestSent] = useState(false); const [inviteInput, setInviteInput] = useState(''); const [inviteStatus, setInviteStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); const dropdownRef = useRef(null); const dropdownMenuRef = useRef(null); const triggerRef = useRef(null); const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null); const boardId = slug || 'mycofi33'; // Get auth headers const getAuthHeaders = (): Record => { const headers: Record = { 'Content-Type': 'application/json', }; if (session.authed && session.username) { const publicKey = crypto.getPublicKey(session.username); if (publicKey) { headers['X-CryptID-PublicKey'] = publicKey; } } return headers; }; // Fetch board info and admin status const fetchBoardData = async () => { setLoading(true); try { const headers = getAuthHeaders(); // Fetch board info const infoRes = await fetch(`${WORKER_URL}/boards/${boardId}/info`, { headers }); const infoData = await infoRes.json() as { board?: BoardInfo }; if (infoData.board) { setBoardInfo(infoData.board); } // Fetch permission to check if admin const permRes = await fetch(`${WORKER_URL}/boards/${boardId}/permission`, { headers }); const permData = await permRes.json() as { permission?: string; isGlobalAdmin?: boolean }; setIsAdmin(permData.permission === 'admin'); setIsGlobalAdmin(permData.isGlobalAdmin || false); // If admin, fetch editors list if (permData.permission === 'admin') { const editorsRes = await fetch(`${WORKER_URL}/boards/${boardId}/editors`, { headers }); const editorsData = await editorsRes.json() as { editors?: Editor[] }; setEditors(editorsData.editors || []); } } catch (error) { console.error('Failed to fetch board data:', error); } finally { setLoading(false); } }; // Toggle board protection const toggleProtection = async () => { if (!boardInfo || updating) return; setUpdating(true); try { const headers = getAuthHeaders(); const res = await fetch(`${WORKER_URL}/boards/${boardId}`, { method: 'PATCH', headers, body: JSON.stringify({ isProtected: !boardInfo.isProtected }), }); if (res.ok) { setBoardInfo(prev => prev ? { ...prev, isProtected: !prev.isProtected } : null); } } catch (error) { console.error('Failed to toggle protection:', error); } finally { setUpdating(false); } }; // Request admin access const requestAdminAccess = async () => { if (requestingAdmin || adminRequestSent) return; setRequestingAdmin(true); try { const headers = getAuthHeaders(); const res = await fetch(`${WORKER_URL}/admin/request`, { method: 'POST', headers, body: JSON.stringify({ reason: `Requesting admin access for board: ${boardId}` }), }); if (res.ok) { setAdminRequestSent(true); } } catch (error) { console.error('Failed to request admin:', error); } finally { setRequestingAdmin(false); } }; // Invite user as editor const inviteEditor = async () => { if (!inviteInput.trim() || inviteStatus === 'sending') return; setInviteStatus('sending'); try { const headers = getAuthHeaders(); const res = await fetch(`${WORKER_URL}/boards/${boardId}/permissions`, { method: 'POST', headers, body: JSON.stringify({ usernameOrEmail: inviteInput.trim(), permission: 'edit', }), }); if (res.ok) { setInviteStatus('sent'); setInviteInput(''); // Refresh editors list fetchBoardData(); setTimeout(() => setInviteStatus('idle'), 2000); } else { setInviteStatus('error'); setTimeout(() => setInviteStatus('idle'), 3000); } } catch (error) { console.error('Failed to invite editor:', error); setInviteStatus('error'); setTimeout(() => setInviteStatus('idle'), 3000); } }; // Remove editor const removeEditor = async (userId: string) => { try { const headers = getAuthHeaders(); await fetch(`${WORKER_URL}/boards/${boardId}/permissions/${userId}`, { method: 'DELETE', headers, }); setEditors(prev => prev.filter(e => e.userId !== userId)); } catch (error) { console.error('Failed to remove editor:', error); } }; // 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, }); fetchBoardData(); } }, [showDropdown]); // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; const isInsideTrigger = dropdownRef.current && dropdownRef.current.contains(target); const isInsideMenu = dropdownMenuRef.current && dropdownMenuRef.current.contains(target); if (!isInsideTrigger && !isInsideMenu) { 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]); return (
{/* Dropdown Menu */} {showDropdown && dropdownPosition && createPortal(
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {/* Header */}
Board Settings
{loading ? (
Loading...
) : (
{/* Board Info */}
Board Info
ID: {boardId}
{boardInfo?.ownerUsername && (
Owner: @{boardInfo.ownerUsername}
)}
Status: {boardInfo?.isProtected ? 'Protected (View-only)' : 'Open (Anyone can edit)'}
{/* Admin Section */} {isAdmin && ( <>
Protection Settings {isGlobalAdmin && (Global Admin)}
{/* Protection Toggle */}
View-only Mode
Only listed editors can make changes
{/* Editor Management (only when protected) */} {boardInfo?.isProtected && (
Editors ({editors.length})
{/* Add Editor Input */}
setInviteInput(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Enter') inviteEditor(); }} style={{ flex: 1, padding: '8px 12px', fontSize: '12px', fontFamily: 'inherit', border: '1px solid var(--color-panel-contrast)', borderRadius: '6px', background: 'var(--color-panel)', color: 'var(--color-text)', outline: 'none', }} />
{/* Editor List */}
{editors.length === 0 ? (
No editors added yet
) : ( editors.map((editor) => (
@{editor.username}
{editor.permission}
)) )}
)} )} {/* Request Admin Access (for non-admins) */} {!isAdmin && session.authed && (
Admin requests are sent to jeffemmett@gmail.com
)} {/* Sign in prompt for anonymous users */} {!session.authed && (
Sign in to access board settings
)}
)}
, document.body )}
); }; export default BoardSettingsDropdown;