feat: RefBoard v0.4.0 — collaborative reference board with layers, groups & polished UI
Full-featured PureRef-style collaborative canvas for game dev teams: - Layer panel with visibility, lock, drag reorder, group/ungroup (Ctrl+G/Shift+G) - Arrangement tools (grid, row, column) via right-click context menu - Copy to system clipboard (Ctrl+C writes PNG for external paste in Paint etc.) - Number shortcuts (1-5) for tool selection with visible shortcut badges - Premium dark UI across all pages (Login, Collections, Boards, Editor) - Socket.IO rooms for cursors, transforms, and presence notifications - MinIO image storage with backend proxy, drag/drop and paste upload
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff',
|
||||
'#00ffff', '#ffffff', '#000000', '#ff6600', '#9933ff',
|
||||
];
|
||||
|
||||
interface ColorPickerProps {
|
||||
color: string;
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
wrapper: {
|
||||
position: 'relative' as const,
|
||||
display: 'inline-block',
|
||||
},
|
||||
trigger: {
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '6px',
|
||||
border: '2px solid #3d3d3d',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
outline: 'none',
|
||||
},
|
||||
popup: {
|
||||
position: 'absolute' as const,
|
||||
top: '36px',
|
||||
left: '0',
|
||||
background: '#2d2d2d',
|
||||
border: '1px solid #3d3d3d',
|
||||
borderRadius: '8px',
|
||||
padding: '10px',
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '8px',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(5, 1fr)',
|
||||
gap: '4px',
|
||||
},
|
||||
swatch: {
|
||||
width: '26px',
|
||||
height: '26px',
|
||||
borderRadius: '4px',
|
||||
border: '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
outline: 'none',
|
||||
},
|
||||
hexRow: {
|
||||
display: 'flex',
|
||||
gap: '6px',
|
||||
alignItems: 'center',
|
||||
},
|
||||
hexLabel: {
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
},
|
||||
hexInput: {
|
||||
flex: 1,
|
||||
padding: '4px 6px',
|
||||
background: '#1a1a1a',
|
||||
border: '1px solid #3d3d3d',
|
||||
borderRadius: '4px',
|
||||
color: '#e0e0e0',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'monospace',
|
||||
outline: 'none',
|
||||
width: '80px',
|
||||
},
|
||||
};
|
||||
|
||||
export default function ColorPicker({ color, onChange }: ColorPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hexInput, setHexInput] = useState(color);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHexInput(color);
|
||||
}, [color]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
function handleHexSubmit() {
|
||||
let hex = hexInput.trim();
|
||||
if (!hex.startsWith('#')) hex = '#' + hex;
|
||||
if (/^#[0-9a-fA-F]{3,8}$/.test(hex)) {
|
||||
onChange(hex);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} style={styles.wrapper}>
|
||||
<button
|
||||
style={{ ...styles.trigger, background: color }}
|
||||
onClick={() => setOpen(!open)}
|
||||
title="Pick color"
|
||||
/>
|
||||
{open && (
|
||||
<div style={styles.popup}>
|
||||
<div style={styles.grid}>
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
style={{
|
||||
...styles.swatch,
|
||||
background: c,
|
||||
borderColor: c === color ? '#4a9eff' : 'transparent',
|
||||
}}
|
||||
onClick={() => {
|
||||
onChange(c);
|
||||
setOpen(false);
|
||||
}}
|
||||
title={c}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div style={styles.hexRow}>
|
||||
<span style={styles.hexLabel}>#</span>
|
||||
<input
|
||||
style={styles.hexInput}
|
||||
value={hexInput.replace('#', '')}
|
||||
onChange={(e) => setHexInput('#' + e.target.value)}
|
||||
onBlur={handleHexSubmit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleHexSubmit();
|
||||
}}
|
||||
maxLength={8}
|
||||
placeholder="hex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
items: MenuItem[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick);
|
||||
document.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
const style: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: x,
|
||||
top: y,
|
||||
zIndex: 1000,
|
||||
background: '#2a2a2a',
|
||||
border: '1px solid #3d3d3d',
|
||||
borderRadius: '8px',
|
||||
padding: '4px 0',
|
||||
minWidth: '180px',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} style={style}>
|
||||
{items.map((item, i) => {
|
||||
if (item.divider) {
|
||||
return <div key={i} style={{ height: '1px', background: '#3d3d3d', margin: '4px 0' }} />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
disabled={item.disabled}
|
||||
onClick={() => { item.onClick(); onClose(); }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '6px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: item.disabled ? '#555' : item.danger ? '#ff6b6b' : '#ddd',
|
||||
fontSize: '12px',
|
||||
cursor: item.disabled ? 'default' : 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!item.disabled) e.currentTarget.style.background = '#363636';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span style={{ color: '#666', fontSize: '11px', marginLeft: '20px' }}>{item.shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
|
||||
interface LayerItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
visible: boolean;
|
||||
locked: boolean;
|
||||
isGroup: boolean;
|
||||
children?: LayerItem[];
|
||||
}
|
||||
|
||||
interface LayerPanelProps {
|
||||
layers: LayerItem[];
|
||||
selectedIds: string[];
|
||||
onSelect: (id: string) => void;
|
||||
onToggleVisible: (id: string) => void;
|
||||
onToggleLock: (id: string) => void;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onGroup: () => void;
|
||||
onUngroup: () => void;
|
||||
hasSelection: boolean;
|
||||
hasGroupSelection: boolean;
|
||||
}
|
||||
|
||||
export default function LayerPanel({
|
||||
layers, selectedIds, onSelect, onToggleVisible, onToggleLock,
|
||||
onReorder, onDelete, onGroup, onUngroup, hasSelection, hasGroupSelection,
|
||||
}: LayerPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
|
||||
const onDragStart = useCallback((idx: number) => setDragIdx(idx), []);
|
||||
const onDragOver = useCallback((e: React.DragEvent) => e.preventDefault(), []);
|
||||
const onDrop = useCallback((targetIdx: number) => {
|
||||
if (dragIdx !== null && dragIdx !== targetIdx) {
|
||||
onReorder(dragIdx, targetIdx);
|
||||
}
|
||||
setDragIdx(null);
|
||||
}, [dragIdx, onReorder]);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: 0, bottom: 0, width: '28px',
|
||||
background: '#1e1e1e', borderLeft: '1px solid #2a2a2a', zIndex: 100,
|
||||
display: 'flex', alignItems: 'flex-start', justifyContent: 'center', paddingTop: '8px',
|
||||
}}>
|
||||
<button onClick={() => setCollapsed(false)} title="Show layers"
|
||||
style={{ background: 'none', border: 'none', color: '#888', cursor: 'pointer', fontSize: '14px', padding: '4px' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M7 2L2 7l5 5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', right: 0, top: 0, bottom: 0, width: '200px',
|
||||
background: '#1e1e1e', borderLeft: '1px solid #2a2a2a', zIndex: 100,
|
||||
display: 'flex', flexDirection: 'column', userSelect: 'none',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '6px 8px', borderBottom: '1px solid #2a2a2a', flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ fontSize: '11px', fontWeight: 600, color: '#999', letterSpacing: '0.5px', textTransform: 'uppercase' }}>
|
||||
Layers
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '2px' }}>
|
||||
<SmallBtn title="Group (Ctrl+G)" disabled={!hasSelection} onClick={onGroup}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||
<rect x="1" y="1" width="4" height="4" rx="0.5" /><rect x="7" y="7" width="4" height="4" rx="0.5" />
|
||||
<path d="M5 6h2M6 5v2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</SmallBtn>
|
||||
<SmallBtn title="Ungroup (Ctrl+Shift+G)" disabled={!hasGroupSelection} onClick={onUngroup}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||
<rect x="1" y="1" width="4" height="4" rx="0.5" /><rect x="7" y="7" width="4" height="4" rx="0.5" />
|
||||
<path d="M4 6h4" strokeLinecap="round" />
|
||||
</svg>
|
||||
</SmallBtn>
|
||||
<SmallBtn title="Collapse panel" onClick={() => setCollapsed(true)}>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M5 2l5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</SmallBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layer list — reversed so top layer is first */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
{[...layers].reverse().map((layer, i) => {
|
||||
const realIdx = layers.length - 1 - i;
|
||||
const selected = selectedIds.includes(layer.id);
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(realIdx)}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={() => onDrop(realIdx)}
|
||||
onClick={() => onSelect(layer.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: '4px',
|
||||
padding: '3px 6px', cursor: 'pointer',
|
||||
background: selected ? '#2a3a50' : dragIdx === realIdx ? '#2a2a2a' : 'transparent',
|
||||
borderBottom: '1px solid #222',
|
||||
opacity: layer.visible ? 1 : 0.4,
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!selected) (e.currentTarget as HTMLDivElement).style.background = '#252525'; }}
|
||||
onMouseLeave={(e) => { if (!selected) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||
>
|
||||
{/* Visibility toggle */}
|
||||
<button onClick={(e) => { e.stopPropagation(); onToggleVisible(layer.id); }}
|
||||
title={layer.visible ? 'Hide' : 'Show'}
|
||||
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', color: layer.visible ? '#888' : '#444', flexShrink: 0 }}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||
{layer.visible ? (
|
||||
<><ellipse cx="5" cy="5" rx="4" ry="2.5" /><circle cx="5" cy="5" r="1" fill="currentColor" /></>
|
||||
) : (
|
||||
<><line x1="1" y1="1" x2="9" y2="9" /><ellipse cx="5" cy="5" rx="4" ry="2.5" /></>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Lock toggle */}
|
||||
<button onClick={(e) => { e.stopPropagation(); onToggleLock(layer.id); }}
|
||||
title={layer.locked ? 'Unlock' : 'Lock'}
|
||||
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', color: layer.locked ? '#e8a946' : '#444', flexShrink: 0 }}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||
{layer.locked ? (
|
||||
<><rect x="2" y="5" width="6" height="4" rx="0.5" /><path d="M3.5 5V3.5a1.5 1.5 0 013 0V5" /></>
|
||||
) : (
|
||||
<><rect x="2" y="5" width="6" height="4" rx="0.5" /><path d="M3.5 5V3.5a1.5 1.5 0 013 0" /></>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Type icon */}
|
||||
<span style={{ fontSize: '9px', color: '#555', flexShrink: 0, width: '12px', textAlign: 'center' }}>
|
||||
{layer.isGroup ? '📁' : layer.type === 'image' ? '🖼' : layer.type === 'i-text' ? 'T' : layer.type === 'path' ? '✏' : '◇'}
|
||||
</span>
|
||||
|
||||
{/* Name */}
|
||||
<span style={{
|
||||
flex: 1, fontSize: '11px', color: selected ? '#ccc' : '#999',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{layer.name}
|
||||
</span>
|
||||
|
||||
{/* Delete */}
|
||||
<button onClick={(e) => { e.stopPropagation(); onDelete(layer.id); }}
|
||||
title="Delete"
|
||||
style={{ background: 'none', border: 'none', padding: '2px', cursor: 'pointer', color: '#444', flexShrink: 0, opacity: 0.5 }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.color = '#ff6b6b'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '0.5'; e.currentTarget.style.color = '#444'; }}>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<line x1="2" y1="2" x2="8" y2="8" /><line x1="8" y1="2" x2="2" y2="8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{layers.length === 0 && (
|
||||
<div style={{ padding: '12px', textAlign: 'center', color: '#444', fontSize: '11px' }}>
|
||||
No objects
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SmallBtn({ onClick, title, disabled, children }: {
|
||||
onClick: () => void; title: string; disabled?: boolean; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button onClick={onClick} title={title} disabled={disabled}
|
||||
style={{
|
||||
background: 'none', border: 'none', padding: '3px', cursor: disabled ? 'default' : 'pointer',
|
||||
color: disabled ? '#333' : '#888', borderRadius: '3px',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.background = '#333'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'none'; }}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { shareCollection, getCollectionShareInfo, getCollectionDetail, addCollectionMember, removeCollectionMember, searchUsers } from '../api';
|
||||
import { useAuth } from '../auth';
|
||||
|
||||
interface Member {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface UserResult {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
interface ShareDialogProps {
|
||||
collectionId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ShareDialog({ collectionId, onClose }: ShareDialogProps) {
|
||||
const { user } = useAuth();
|
||||
const [pub, setPub] = useState(false);
|
||||
const [shareToken, setShareToken] = useState<string | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [ownerId, setOwnerId] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [role, setRole] = useState('editor');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [suggestions, setSuggestions] = useState<UserResult[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<UserResult | null>(null);
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isOwner = user?.id === ownerId;
|
||||
const shareUrl = shareToken ? `${window.location.origin}/c/${shareToken}` : '';
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [shareRes, detailRes] = await Promise.all([
|
||||
getCollectionShareInfo(collectionId),
|
||||
getCollectionDetail(collectionId),
|
||||
]);
|
||||
setPub(shareRes.data.is_public);
|
||||
setShareToken(shareRes.data.share_token);
|
||||
setMembers(detailRes.data.members || []);
|
||||
setOwnerId(detailRes.data.collection?.created_by || '');
|
||||
} catch (err) {
|
||||
console.error('Failed to load share data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadData(); }, [collectionId]);
|
||||
|
||||
function handleQueryChange(val: string) {
|
||||
setQuery(val);
|
||||
setSelectedUser(null);
|
||||
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
if (val.length < 1) {
|
||||
setSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await searchUsers(val);
|
||||
const memberIds = new Set(members.map(m => m.user_id));
|
||||
const filtered = (res.data.users || []).filter(
|
||||
(u: UserResult) => u.id !== user?.id && !memberIds.has(u.id)
|
||||
);
|
||||
setSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
} catch {
|
||||
setSuggestions([]);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function selectUser(u: UserResult) {
|
||||
setSelectedUser(u);
|
||||
setQuery(u.display_name || u.email);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
|
||||
async function handleTogglePublic() {
|
||||
try {
|
||||
const res = await shareCollection(collectionId, !pub);
|
||||
setPub(res.data.is_public);
|
||||
setShareToken(res.data.share_token);
|
||||
} catch (err) {
|
||||
console.error('Failed to update share settings:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!shareUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
} catch {
|
||||
const input = document.createElement('input');
|
||||
input.value = shareUrl;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
async function handleAddMember() {
|
||||
const target = selectedUser;
|
||||
if (!target) {
|
||||
// Try searching by exact email
|
||||
if (!query.trim()) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await addCollectionMember(collectionId, query.trim(), role);
|
||||
setQuery('');
|
||||
setSelectedUser(null);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'User not found');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setAdding(true);
|
||||
try {
|
||||
await addCollectionMember(collectionId, target.email, role);
|
||||
setQuery('');
|
||||
setSelectedUser(null);
|
||||
setSuggestions([]);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Failed to add member');
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(userId: string) {
|
||||
if (!confirm('Remove this member?')) return;
|
||||
try {
|
||||
await removeCollectionMember(collectionId, userId);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error('Failed to remove member:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={s.overlay} onClick={onClose}>
|
||||
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ color: '#666', textAlign: 'center', padding: '20px', fontSize: '13px' }}>Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={s.overlay} onClick={onClose}>
|
||||
<div style={s.modal} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={s.header}>
|
||||
<h2 style={s.title}>Share Collection</h2>
|
||||
<button style={s.closeBtn} onClick={onClose}>{'\u00D7'}</button>
|
||||
</div>
|
||||
|
||||
{/* Visibility */}
|
||||
<div style={s.section}>
|
||||
<div style={s.sectionTitle}>Visibility</div>
|
||||
<div style={s.toggleRow}>
|
||||
<span style={{ fontSize: '13px', color: '#ccc' }}>
|
||||
{pub ? 'Public — anyone with link can view' : 'Private — members only'}
|
||||
</span>
|
||||
{isOwner && (
|
||||
<button
|
||||
style={{ ...s.toggleSwitch, background: pub ? '#4a9eff' : '#555' }}
|
||||
onClick={handleTogglePublic}
|
||||
>
|
||||
<div style={{ ...s.toggleKnob, left: pub ? '23px' : '3px' }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pub && shareUrl && (
|
||||
<div style={s.linkRow}>
|
||||
<input style={s.linkInput} value={shareUrl} readOnly onClick={(e) => (e.target as HTMLInputElement).select()} />
|
||||
<button style={{ ...s.copyBtn, background: copied ? '#4ade80' : '#4a9eff' }} onClick={handleCopy}>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
<div style={s.section}>
|
||||
<div style={s.sectionTitle}>Members ({members.length})</div>
|
||||
|
||||
{isOwner && (
|
||||
<div style={{ position: 'relative', marginBottom: '12px' }}>
|
||||
<div style={s.addRow}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
style={s.input}
|
||||
type="text"
|
||||
placeholder="Search by name or email..."
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
onFocus={() => { if (suggestions.length > 0) setShowSuggestions(true); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleAddMember(); }}
|
||||
/>
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)} style={s.roleSelect}>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<button style={{ ...s.addBtn, opacity: adding ? 0.7 : 1 }} onClick={handleAddMember} disabled={adding}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{showSuggestions && (
|
||||
<div style={s.dropdown}>
|
||||
{suggestions.map((u) => (
|
||||
<button key={u.id} style={s.dropdownItem} onClick={() => selectUser(u)}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<span style={{ fontSize: '13px', color: '#e0e0e0' }}>{u.display_name}</span>
|
||||
<span style={{ fontSize: '11px', color: '#888' }}>{u.email}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member list */}
|
||||
{members.map((m) => (
|
||||
<div key={m.user_id} style={s.memberRow}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', minWidth: 0 }}>
|
||||
<div style={s.avatar}>
|
||||
{(m.display_name || m.email)[0].toUpperCase()}
|
||||
</div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: '13px', color: '#e0e0e0', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{m.display_name}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#888', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{m.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
||||
<span style={getRoleBadgeStyle(m.role)}>{m.role}</span>
|
||||
{isOwner && m.role !== 'owner' && (
|
||||
<button style={s.removeBtn} onClick={() => handleRemove(m.user_id)}>Remove</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{members.length === 0 && (
|
||||
<div style={{ fontSize: '13px', color: '#555', padding: '10px 0' }}>No members yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRoleBadgeStyle(role: string): React.CSSProperties {
|
||||
const colors: Record<string, { bg: string; color: string }> = {
|
||||
owner: { bg: 'rgba(255, 215, 0, 0.12)', color: '#ffd700' },
|
||||
editor: { bg: 'rgba(74, 158, 255, 0.12)', color: '#4a9eff' },
|
||||
viewer: { bg: 'rgba(136, 136, 136, 0.12)', color: '#888' },
|
||||
};
|
||||
const c = colors[role] || colors.viewer;
|
||||
return {
|
||||
fontSize: '10px', padding: '2px 8px', borderRadius: '4px',
|
||||
fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.3px',
|
||||
background: c.bg, color: c.color,
|
||||
};
|
||||
}
|
||||
|
||||
const s = {
|
||||
overlay: { position: 'fixed' as const, inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 },
|
||||
modal: { background: '#252525', borderRadius: '10px', padding: '24px', width: '100%', maxWidth: '440px', border: '1px solid #333', maxHeight: '80vh', overflow: 'auto' },
|
||||
header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '20px' },
|
||||
title: { margin: 0, fontSize: '16px', fontWeight: 600, color: '#e0e0e0' },
|
||||
closeBtn: { background: 'transparent', border: 'none', color: '#666', fontSize: '18px', cursor: 'pointer', padding: '4px', lineHeight: 1 },
|
||||
section: { marginBottom: '18px' },
|
||||
sectionTitle: { fontSize: '11px', fontWeight: 600, color: '#666', marginBottom: '8px', textTransform: 'uppercase' as const, letterSpacing: '0.5px' },
|
||||
toggleRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 12px', background: '#1e1e1e', borderRadius: '6px', border: '1px solid #333' },
|
||||
toggleSwitch: { position: 'relative' as const, width: '44px', height: '24px', borderRadius: '12px', cursor: 'pointer', transition: 'background 0.2s', border: 'none', padding: 0 },
|
||||
toggleKnob: { position: 'absolute' as const, top: '3px', width: '18px', height: '18px', borderRadius: '50%', background: '#fff', transition: 'left 0.2s' },
|
||||
linkRow: { display: 'flex', gap: '6px', marginTop: '8px' },
|
||||
linkInput: { flex: 1, padding: '7px 10px', background: '#1e1e1e', border: '1px solid #333', borderRadius: '5px', color: '#ccc', fontSize: '12px', outline: 'none', fontFamily: 'monospace' },
|
||||
copyBtn: { padding: '7px 14px', color: '#fff', border: 'none', borderRadius: '5px', fontSize: '12px', fontWeight: 600, cursor: 'pointer', whiteSpace: 'nowrap' as const },
|
||||
addRow: { display: 'flex', gap: '6px' },
|
||||
input: { flex: 1, padding: '7px 10px', background: '#1e1e1e', border: '1px solid #333', borderRadius: '5px', color: '#e0e0e0', fontSize: '12px', outline: 'none' },
|
||||
roleSelect: { padding: '7px 8px', background: '#1e1e1e', border: '1px solid #333', borderRadius: '5px', color: '#e0e0e0', fontSize: '12px', cursor: 'pointer', outline: 'none' },
|
||||
addBtn: { padding: '7px 14px', background: '#4a9eff', color: '#fff', border: 'none', borderRadius: '5px', fontSize: '12px', fontWeight: 600, cursor: 'pointer' },
|
||||
dropdown: { position: 'absolute' as const, top: '100%', left: 0, right: 0, background: '#2d2d2d', border: '1px solid #444', borderRadius: '6px', marginTop: '4px', overflow: 'hidden', zIndex: 10, boxShadow: '0 4px 12px rgba(0,0,0,0.4)' },
|
||||
dropdownItem: { display: 'flex', width: '100%', padding: '8px 12px', background: 'transparent', border: 'none', borderBottom: '1px solid #333', cursor: 'pointer', textAlign: 'left' as const },
|
||||
memberRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 10px', background: '#1e1e1e', borderRadius: '6px', marginBottom: '4px', border: '1px solid #2a2a2a' },
|
||||
avatar: { width: '28px', height: '28px', borderRadius: '50%', background: '#4a9eff20', color: '#4a9eff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '12px', fontWeight: 700, flexShrink: 0 },
|
||||
removeBtn: { background: 'transparent', border: '1px solid #5a2d2d', borderRadius: '4px', color: '#ff6b6b', fontSize: '10px', padding: '3px 8px', cursor: 'pointer' },
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
|
||||
export type SaveStatus = 'saved' | 'saving' | 'unsaved';
|
||||
|
||||
interface StatusBarProps {
|
||||
boardName: string;
|
||||
imageCount: number;
|
||||
saveStatus: SaveStatus;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
bar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px 16px',
|
||||
background: '#2d2d2d',
|
||||
borderTop: '1px solid #3d3d3d',
|
||||
fontSize: '12px',
|
||||
color: '#888',
|
||||
flexShrink: 0,
|
||||
height: '28px',
|
||||
},
|
||||
left: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
},
|
||||
name: {
|
||||
color: '#aaa',
|
||||
fontWeight: 500,
|
||||
},
|
||||
right: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
},
|
||||
dot: {
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
display: 'inline-block',
|
||||
},
|
||||
};
|
||||
|
||||
const statusConfig: Record<SaveStatus, { label: string; color: string }> = {
|
||||
saved: { label: 'Saved', color: '#69db7c' },
|
||||
saving: { label: 'Saving...', color: '#ffd43b' },
|
||||
unsaved: { label: 'Unsaved changes', color: '#ff6b6b' },
|
||||
};
|
||||
|
||||
export default function StatusBar({ boardName, imageCount, saveStatus }: StatusBarProps) {
|
||||
const status = statusConfig[saveStatus];
|
||||
|
||||
return (
|
||||
<div style={styles.bar}>
|
||||
<div style={styles.left}>
|
||||
<span style={styles.name}>{boardName}</span>
|
||||
<span>{imageCount} image{imageCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div style={styles.right}>
|
||||
<span style={{ ...styles.dot, background: status.color }} />
|
||||
<span>{status.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import React from 'react';
|
||||
import { ToolType } from '../canvas/tools';
|
||||
import ColorPicker from './ColorPicker';
|
||||
|
||||
interface OnlineUser {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ToolbarProps {
|
||||
activeTool: ToolType;
|
||||
onToolChange: (tool: ToolType) => void;
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
strokeWidth: number;
|
||||
onStrokeWidthChange: (width: number) => void;
|
||||
fontSize: number;
|
||||
onFontSizeChange: (size: number) => void;
|
||||
zoom: number;
|
||||
onFitAll: () => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onlineUsers: OnlineUser[];
|
||||
onShareClick?: () => void;
|
||||
onToggleLayers?: () => void;
|
||||
showLayers?: boolean;
|
||||
boardName?: string;
|
||||
}
|
||||
|
||||
// SVG icon components
|
||||
function IconSelect() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 2L3 13L7 9.5L11 13.5L13 11.5L9 7.5L13 4L3 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPan() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M8 1v14M1 8h14M4 4L8 1L12 4M4 12L8 15L12 12M1 4L4 8L1 12M15 4L12 8L15 12" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPen() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M2 14L3.5 8.5L11 1L15 5L7.5 12.5L2 14Z" strokeLinejoin="round" />
|
||||
<path d="M3.5 8.5L7.5 12.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconText() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M3 3h10M8 3v11M5 14h6" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconEraser() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6.5 14H14M2 10l4.5-4.5 4 4L6 14H3l-1-1v-3z" strokeLinejoin="round" />
|
||||
<path d="M6.5 5.5L14 2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconUndo() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 5h6a3 3 0 010 6H7" strokeLinecap="round" />
|
||||
<path d="M5 3L3 5L5 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconRedo() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M11 5H5a3 3 0 000 6h2" strokeLinecap="round" />
|
||||
<path d="M9 3L11 5L9 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconFit() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
||||
<path d="M1 5V1h4M9 1h4v4M13 9v4H9M5 13H1V9" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconLayers() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<path d="M7 1L1 4.5L7 8L13 4.5L7 1Z" strokeLinejoin="round" />
|
||||
<path d="M1 7l6 3.5L13 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M1 9.5L7 13l6-3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const toolButtons: { tool: ToolType; label: string; shortcut: string; numKey: string; Icon: React.FC }[] = [
|
||||
{ tool: ToolType.SELECT, label: 'Select', shortcut: 'V', numKey: '1', Icon: IconSelect },
|
||||
{ tool: ToolType.PAN, label: 'Pan', shortcut: 'H', numKey: '2', Icon: IconPan },
|
||||
{ tool: ToolType.PEN, label: 'Draw', shortcut: 'P', numKey: '3', Icon: IconPen },
|
||||
{ tool: ToolType.TEXT, label: 'Text', shortcut: 'T', numKey: '4', Icon: IconText },
|
||||
{ tool: ToolType.ERASER, label: 'Eraser', shortcut: 'E', numKey: '5', Icon: IconEraser },
|
||||
];
|
||||
|
||||
export default function Toolbar({
|
||||
activeTool,
|
||||
onToolChange,
|
||||
color,
|
||||
onColorChange,
|
||||
strokeWidth,
|
||||
onStrokeWidthChange,
|
||||
fontSize,
|
||||
onFontSizeChange,
|
||||
zoom,
|
||||
onFitAll,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onlineUsers,
|
||||
onShareClick,
|
||||
onToggleLayers,
|
||||
showLayers,
|
||||
}: ToolbarProps) {
|
||||
const showStroke = activeTool === ToolType.PEN;
|
||||
const showFontSize = activeTool === ToolType.TEXT;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
padding: '4px 8px',
|
||||
background: '#1a1a1a',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
flexShrink: 0,
|
||||
height: '44px',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
{/* Tool buttons */}
|
||||
<div style={{ display: 'flex', gap: '1px', background: '#222', borderRadius: '8px', padding: '2px' }}>
|
||||
{toolButtons.map(({ tool, label, shortcut, numKey, Icon }) => {
|
||||
const active = activeTool === tool;
|
||||
return (
|
||||
<button
|
||||
key={tool}
|
||||
onClick={() => onToolChange(tool)}
|
||||
title={`${label} (${shortcut} or ${numKey})`}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
gap: '4px', height: '32px', padding: '0 10px',
|
||||
background: active ? 'linear-gradient(135deg, #4a9eff, #3d7dd8)' : 'transparent',
|
||||
border: 'none', borderRadius: '6px',
|
||||
color: active ? '#fff' : '#777',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: active ? '0 1px 4px rgba(74,158,255,0.3)' : 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!active) { e.currentTarget.style.background = '#2a2a2a'; e.currentTarget.style.color = '#bbb'; } }}
|
||||
onMouseLeave={(e) => { if (!active) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#777'; } }}
|
||||
>
|
||||
<Icon />
|
||||
<span style={{ fontSize: '11px', fontWeight: active ? 600 : 400, letterSpacing: '0.2px' }}>{label}</span>
|
||||
<span style={{
|
||||
fontSize: '9px', color: active ? 'rgba(255,255,255,0.5)' : '#555',
|
||||
background: active ? 'rgba(255,255,255,0.1)' : '#1a1a1a',
|
||||
padding: '1px 4px', borderRadius: '3px', fontWeight: 500,
|
||||
lineHeight: '14px', minWidth: '14px', textAlign: 'center',
|
||||
}}>
|
||||
{numKey}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Color */}
|
||||
<ColorPicker color={color} onChange={onColorChange} />
|
||||
|
||||
{/* Stroke width (pen) */}
|
||||
{showStroke && (
|
||||
<>
|
||||
<Divider />
|
||||
<span style={{ fontSize: '10px', color: '#555', marginLeft: '4px' }}>Width</span>
|
||||
<input
|
||||
type="range" min={2} max={20} value={strokeWidth}
|
||||
onChange={(e) => onStrokeWidthChange(Number(e.target.value))}
|
||||
style={{ width: '60px', height: '3px', accentColor: '#4a9eff', cursor: 'pointer' }}
|
||||
/>
|
||||
<span style={{ fontSize: '10px', color: '#666', minWidth: '18px', textAlign: 'center' }}>{strokeWidth}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Font size (text) */}
|
||||
{showFontSize && (
|
||||
<>
|
||||
<Divider />
|
||||
<span style={{ fontSize: '10px', color: '#555', marginLeft: '4px' }}>Size</span>
|
||||
<input
|
||||
type="range" min={12} max={72} value={fontSize}
|
||||
onChange={(e) => onFontSizeChange(Number(e.target.value))}
|
||||
style={{ width: '60px', height: '3px', accentColor: '#4a9eff', cursor: 'pointer' }}
|
||||
/>
|
||||
<span style={{ fontSize: '10px', color: '#666', minWidth: '18px', textAlign: 'center' }}>{fontSize}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<ActionBtn onClick={onUndo} disabled={!canUndo} title="Undo (Ctrl+Z)"><IconUndo /></ActionBtn>
|
||||
<ActionBtn onClick={onRedo} disabled={!canRedo} title="Redo (Ctrl+Shift+Z)"><IconRedo /></ActionBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Zoom */}
|
||||
{onZoomOut && (
|
||||
<ActionBtn onClick={onZoomOut} title="Zoom out (Ctrl+-)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<line x1="3" y1="7" x2="11" y2="7" strokeLinecap="round" />
|
||||
</svg>
|
||||
</ActionBtn>
|
||||
)}
|
||||
<span style={{
|
||||
fontSize: '11px', color: '#888', minWidth: '40px', textAlign: 'center',
|
||||
userSelect: 'none', fontVariantNumeric: 'tabular-nums',
|
||||
}}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
{onZoomIn && (
|
||||
<ActionBtn onClick={onZoomIn} title="Zoom in (Ctrl+=)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<line x1="3" y1="7" x2="11" y2="7" strokeLinecap="round" />
|
||||
<line x1="7" y1="3" x2="7" y2="11" strokeLinecap="round" />
|
||||
</svg>
|
||||
</ActionBtn>
|
||||
)}
|
||||
<ActionBtn onClick={onFitAll} title="Fit all (Ctrl+0)"><IconFit /></ActionBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Layers toggle */}
|
||||
{onToggleLayers && (
|
||||
<ActionBtn onClick={onToggleLayers} title="Layers panel"
|
||||
active={showLayers}>
|
||||
<IconLayers />
|
||||
</ActionBtn>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* Online users */}
|
||||
{onlineUsers.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '-4px', marginRight: '8px' }}
|
||||
title={onlineUsers.map((u) => u.displayName).join(', ')}>
|
||||
{onlineUsers.slice(0, 5).map((u, i) => (
|
||||
<div key={u.userId} style={{
|
||||
width: '24px', height: '24px', borderRadius: '50%',
|
||||
background: `linear-gradient(135deg, ${u.color}, ${u.color}dd)`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '10px', fontWeight: 700, color: '#fff',
|
||||
border: '2px solid #1a1a1a',
|
||||
marginLeft: i > 0 ? '-6px' : '0',
|
||||
zIndex: 5 - i,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
{(u.displayName || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
{onlineUsers.length > 5 && (
|
||||
<span style={{ fontSize: '10px', color: '#666', marginLeft: '4px' }}>+{onlineUsers.length - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Share */}
|
||||
{onShareClick && (
|
||||
<button onClick={onShareClick} style={{
|
||||
padding: '5px 14px', background: 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||
border: 'none', borderRadius: '6px',
|
||||
color: '#fff', fontSize: '11px', fontWeight: 600,
|
||||
cursor: 'pointer', letterSpacing: '0.3px',
|
||||
boxShadow: '0 1px 4px rgba(74,158,255,0.3)',
|
||||
transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.85'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}>
|
||||
Share
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div style={{ width: '1px', height: '20px', background: '#2a2a2a', margin: '0 6px', flexShrink: 0 }} />;
|
||||
}
|
||||
|
||||
function ActionBtn({ onClick, disabled, title, children, active }: {
|
||||
onClick: () => void; disabled?: boolean; title: string; children: React.ReactNode; active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick} disabled={disabled} title={title}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: '28px', height: '28px', background: active ? '#2a3a50' : 'transparent',
|
||||
border: 'none', borderRadius: '6px',
|
||||
color: active ? '#4a9eff' : disabled ? '#333' : '#777',
|
||||
cursor: disabled ? 'default' : 'pointer', padding: 0,
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!disabled) { e.currentTarget.style.background = active ? '#2a3a50' : '#2a2a2a'; } }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = active ? '#2a3a50' : 'transparent'; }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Socket } from 'socket.io-client';
|
||||
|
||||
interface CursorData {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const CURSOR_COLORS = [
|
||||
'#ff6b6b', '#ffa94d', '#ffd43b', '#69db7c', '#38d9a9',
|
||||
'#4dabf7', '#7950f2', '#e64980', '#20c997', '#ff922b',
|
||||
];
|
||||
|
||||
function userColor(userId: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < userId.length; i++) {
|
||||
hash = ((hash << 5) - hash) + userId.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
||||
}
|
||||
|
||||
interface UserCursorsProps {
|
||||
socket: Socket | null;
|
||||
boardId: string;
|
||||
canvasTransform: number[];
|
||||
}
|
||||
|
||||
export default function UserCursors({ socket, boardId, canvasTransform }: UserCursorsProps) {
|
||||
const [cursors, setCursors] = useState<Map<string, CursorData>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
function handleCursorMove(data: any) {
|
||||
const uid = data.userId || data.id;
|
||||
const name = data.displayName || data.userName || data.display_name || '';
|
||||
if (!uid) return;
|
||||
setCursors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(uid, { userId: uid, displayName: name, x: data.x, y: data.y, color: userColor(uid) });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleUserLeft(data: any) {
|
||||
const uid = data.userId || data.id;
|
||||
if (!uid) return;
|
||||
setCursors((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(uid);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('cursor:moved', handleCursorMove);
|
||||
socket.on('user:left', handleUserLeft);
|
||||
|
||||
return () => {
|
||||
socket.off('cursor:moved', handleCursorMove);
|
||||
socket.off('user:left', handleUserLeft);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
if (cursors.size === 0) return null;
|
||||
|
||||
const [zoom, , , , panX, panY] = canvasTransform.length >= 6
|
||||
? canvasTransform
|
||||
: [1, 0, 0, 1, 0, 0];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{Array.from(cursors.values()).map((cursor) => {
|
||||
// Transform canvas coords to screen coords
|
||||
const screenX = cursor.x * zoom + panX;
|
||||
const screenY = cursor.y * zoom + panY;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cursor.userId}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: screenX,
|
||||
top: screenY,
|
||||
transform: 'translate(-2px, -2px)',
|
||||
transition: 'left 0.1s, top 0.1s',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="20" viewBox="0 0 16 20" fill="none">
|
||||
<path
|
||||
d="M1 1L6 18L8.5 10.5L15 8.5L1 1Z"
|
||||
fill={cursor.color}
|
||||
stroke="#000"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '14px',
|
||||
top: '14px',
|
||||
background: cursor.color,
|
||||
color: '#000',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{cursor.displayName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user