feat: add invite/share feature with QR code, URL, NFC, and audio connect

- Add InviteDialog component with tabbed interface for sharing boards
- Add ShareBoardButton component to toolbar
- Integrate qrcode.react for QR code generation
- Implement Web NFC API for NFC tag writing
- Add placeholder for audio connect feature (coming soon)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-08 05:33:11 +01:00
parent 633607fe25
commit 81140bd397
5 changed files with 437 additions and 1 deletions

12
package-lock.json generated
View File

@ -50,6 +50,7 @@
"marked": "^15.0.4",
"one-webcrypto": "^1.0.3",
"openai": "^4.79.3",
"qrcode.react": "^4.2.0",
"rbush": "^4.0.1",
"react": "^18.2.0",
"react-cmdk": "^1.3.9",
@ -80,6 +81,7 @@
},
"engines": {
"node": ">=20.0.0"
<<<<<<< HEAD
}
},
"multmux/packages/cli": {
@ -197,6 +199,8 @@
"utf-8-validate": {
"optional": true
}
=======
>>>>>>> db7bbbf (feat: add invite/share feature with QR code, URL, NFC, and audio connect)
}
},
"node_modules/@ai-sdk/provider": {
@ -14887,6 +14891,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",

View File

@ -67,6 +67,7 @@
"marked": "^15.0.4",
"one-webcrypto": "^1.0.3",
"openai": "^4.79.3",
"qrcode.react": "^4.2.0",
"rbush": "^4.0.1",
"react": "^18.2.0",
"react-cmdk": "^1.3.9",

View File

@ -0,0 +1,70 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { useDialogs } from 'tldraw';
import { InviteDialog } from '../ui/InviteDialog';
interface ShareBoardButtonProps {
className?: string;
}
const ShareBoardButton: React.FC<ShareBoardButtonProps> = ({ className = '' }) => {
const { slug } = useParams<{ slug: string }>();
const { addDialog, removeDialog } = useDialogs();
const handleShare = () => {
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}
/>
),
});
};
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";
}}
>
<span style={{ fontSize: "12px" }}>Share</span>
</button>
);
};
export default ShareBoardButton;

View File

@ -8,6 +8,7 @@ import { SettingsDialog } from "./SettingsDialog"
import { useAuth } from "../context/AuthContext"
import LoginButton from "../components/auth/LoginButton"
import StarBoardButton from "../components/StarBoardButton"
import ShareBoardButton from "../components/ShareBoardButton"
import { ObsidianVaultBrowser } from "../components/ObsidianVaultBrowser"
import { HolonBrowser } from "../components/HolonBrowser"
import { ObsNoteShape } from "../shapes/ObsNoteShapeUtil"
@ -652,8 +653,8 @@ export function CustomToolbar() {
}}
>
<LoginButton className="toolbar-btn" />
<ShareBoardButton className="toolbar-btn" />
<StarBoardButton className="toolbar-btn" />
{session.authed && (
<div style={{ position: "relative" }}>
<button

352
src/ui/InviteDialog.tsx Normal file
View File

@ -0,0 +1,352 @@
import {
TLUiDialogProps,
TldrawUiButton,
TldrawUiButtonLabel,
TldrawUiDialogBody,
TldrawUiDialogCloseButton,
TldrawUiDialogFooter,
TldrawUiDialogHeader,
TldrawUiDialogTitle,
} from "tldraw"
import React, { useState, useEffect } from "react"
import { QRCodeSVG } from "qrcode.react"
interface InviteDialogProps extends TLUiDialogProps {
boardUrl: string
boardSlug: string
}
type TabType = 'qr' | 'url' | 'nfc' | 'audio'
export function InviteDialog({ onClose, boardUrl, boardSlug }: InviteDialogProps) {
const [activeTab, setActiveTab] = useState<TabType>('qr')
const [copied, setCopied] = useState(false)
const [nfcStatus, setNfcStatus] = useState<'idle' | 'writing' | 'success' | 'error' | 'unsupported'>('idle')
const [nfcMessage, setNfcMessage] = useState('')
// Check NFC support on mount
useEffect(() => {
if (!('NDEFReader' in window)) {
setNfcStatus('unsupported')
}
}, [])
const handleCopyUrl = async () => {
try {
await navigator.clipboard.writeText(boardUrl)
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: boardUrl }
]
})
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'}`)
}
}
}
const tabStyle = (tab: TabType) => ({
flex: 1,
padding: '10px 16px',
border: 'none',
background: activeTab === tab ? '#3b82f6' : '#f3f4f6',
color: activeTab === tab ? 'white' : '#374151',
cursor: 'pointer',
fontWeight: activeTab === tab ? 600 : 400,
fontSize: '13px',
transition: 'all 0.2s ease',
borderRadius: tab === 'qr' ? '6px 0 0 6px' : tab === 'audio' ? '0 6px 6px 0' : '0',
})
return (
<>
<TldrawUiDialogHeader>
<TldrawUiDialogTitle>Invite to Board</TldrawUiDialogTitle>
<TldrawUiDialogCloseButton />
</TldrawUiDialogHeader>
<TldrawUiDialogBody style={{ maxWidth: 420, minHeight: 380 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Board name display */}
<div style={{
textAlign: 'center',
padding: '8px 12px',
backgroundColor: '#f8fafc',
borderRadius: '6px',
border: '1px solid #e2e8f0'
}}>
<span style={{ fontSize: '12px', color: '#64748b' }}>Board: </span>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#1e293b' }}>{boardSlug}</span>
</div>
{/* Tab navigation */}
<div style={{ display: 'flex', borderRadius: '6px', overflow: 'hidden' }}>
<button style={tabStyle('qr')} onClick={() => setActiveTab('qr')}>
QR Code
</button>
<button style={tabStyle('url')} onClick={() => setActiveTab('url')}>
URL
</button>
<button style={tabStyle('nfc')} onClick={() => setActiveTab('nfc')}>
NFC
</button>
<button style={tabStyle('audio')} onClick={() => setActiveTab('audio')}>
Audio
</button>
</div>
{/* Tab content */}
<div style={{
minHeight: 220,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '16px',
backgroundColor: '#fafafa',
borderRadius: '8px',
border: '1px solid #e5e7eb'
}}>
{activeTab === 'qr' && (
<div style={{ textAlign: 'center' }}>
<div style={{
padding: '16px',
backgroundColor: 'white',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
display: 'inline-block'
}}>
<QRCodeSVG
value={boardUrl}
size={180}
level="M"
includeMargin={false}
/>
</div>
<p style={{
marginTop: '16px',
fontSize: '13px',
color: '#6b7280',
lineHeight: 1.5
}}>
Scan this QR code with a mobile device to join the board
</p>
</div>
)}
{activeTab === 'url' && (
<div style={{ width: '100%', textAlign: 'center' }}>
<div style={{
padding: '12px',
backgroundColor: 'white',
borderRadius: '8px',
border: '1px solid #d1d5db',
marginBottom: '16px',
wordBreak: 'break-all',
fontSize: '13px',
fontFamily: 'monospace',
color: '#374151'
}}>
{boardUrl}
</div>
<button
onClick={handleCopyUrl}
style={{
padding: '10px 24px',
backgroundColor: copied ? '#10b981' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '8px',
margin: '0 auto'
}}
>
{copied ? (
<>
<span>Copied!</span>
</>
) : (
<>
<span>Copy URL</span>
</>
)}
</button>
<p style={{
marginTop: '16px',
fontSize: '13px',
color: '#6b7280'
}}>
Share this link with anyone to invite them to your board
</p>
</div>
)}
{activeTab === 'nfc' && (
<div style={{ width: '100%', textAlign: 'center' }}>
{nfcStatus === 'unsupported' ? (
<>
<div style={{
fontSize: '48px',
marginBottom: '16px',
opacity: 0.5
}}>
NFC
</div>
<p style={{
fontSize: '14px',
color: '#6b7280',
marginBottom: '8px'
}}>
NFC is not supported on this device
</p>
<p style={{
fontSize: '12px',
color: '#9ca3af'
}}>
Try using a mobile device with NFC capability
</p>
</>
) : (
<>
<div style={{
fontSize: '48px',
marginBottom: '16px',
animation: nfcStatus === 'writing' ? 'pulse 1.5s infinite' : 'none'
}}>
{nfcStatus === 'success' ? '(done)' : nfcStatus === 'error' ? '(!)' : 'NFC'}
</div>
<button
onClick={handleNfcWrite}
disabled={nfcStatus === 'writing'}
style={{
padding: '10px 24px',
backgroundColor: nfcStatus === 'success' ? '#10b981' :
nfcStatus === 'error' ? '#ef4444' :
nfcStatus === 'writing' ? '#9ca3af' : '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: nfcStatus === 'writing' ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 500,
marginBottom: '12px'
}}
>
{nfcStatus === 'writing' ? 'Writing...' :
nfcStatus === 'success' ? 'Written!' :
'Write to NFC Tag'}
</button>
{nfcMessage && (
<p style={{
fontSize: '13px',
color: nfcStatus === 'error' ? '#ef4444' :
nfcStatus === 'success' ? '#10b981' : '#6b7280'
}}>
{nfcMessage}
</p>
)}
{!nfcMessage && (
<p style={{
fontSize: '13px',
color: '#6b7280'
}}>
Write the board URL to an NFC tag for instant access
</p>
)}
</>
)}
</div>
)}
{activeTab === 'audio' && (
<div style={{ width: '100%', textAlign: 'center' }}>
<div style={{
fontSize: '48px',
marginBottom: '16px',
opacity: 0.6
}}>
((( )))
</div>
<p style={{
fontSize: '14px',
color: '#374151',
fontWeight: 500,
marginBottom: '8px'
}}>
Audio Connect
</p>
<p style={{
fontSize: '13px',
color: '#6b7280',
marginBottom: '16px'
}}>
Share the board link via ultrasonic audio
</p>
<div style={{
padding: '12px 16px',
backgroundColor: '#fef3c7',
borderRadius: '8px',
border: '1px solid #fcd34d',
fontSize: '12px',
color: '#92400e'
}}>
Coming soon! Audio-based sharing will allow nearby devices to join by listening for an ultrasonic signal.
</div>
</div>
)}
</div>
</div>
</TldrawUiDialogBody>
<TldrawUiDialogFooter>
<TldrawUiButton type="primary" onClick={onClose}>
<TldrawUiButtonLabel>Done</TldrawUiButtonLabel>
</TldrawUiButton>
</TldrawUiDialogFooter>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`}</style>
</>
)
}