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:
parent
633607fe25
commit
81140bd397
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue