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:
Hiren
2026-03-09 13:57:49 +05:30
commit e47777d237
43 changed files with 10276 additions and 0 deletions
+341
View File
@@ -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>
);
}