InboxZone: a PixiJS Container positioned at (-500,-500) with a
dashed border and "Inbox" label. Auto-synced media slides in with spring animations and random offset/rotation for a natural look. MattermostImport: modal dialog for pulling images from a MM thread URL and managing linked channels for auto-sync. Uses the existing dark modal styling pattern from ShortcutsHelp. Editor wiring: toolbar button for MM import, InboxZone added to viewport on init, socket listener for board:media-arrived events that routes assets into the InboxZone with a toast notification.
This commit is contained in:
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* InboxZone — visual drop-zone on the canvas where auto-synced media arrives.
|
||||||
|
*
|
||||||
|
* Positioned at top-left of the world (outside typical content area).
|
||||||
|
* Shows a dashed border rectangle with an "Inbox" label.
|
||||||
|
* Items slide in with spring animations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Container, Graphics, Text, TextStyle, Sprite, Texture } from 'pixi.js';
|
||||||
|
import { TextureManager } from './TextureManager';
|
||||||
|
import { SpringManager, Spring, PRESETS } from './spring';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// InboxZone
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class InboxZone extends Container {
|
||||||
|
private border: Graphics;
|
||||||
|
private labelText: Text;
|
||||||
|
private mediaContainer: Container;
|
||||||
|
private textures: TextureManager;
|
||||||
|
private springs: SpringManager;
|
||||||
|
|
||||||
|
/** Width of the inbox zone in world units. */
|
||||||
|
static readonly WIDTH = 300;
|
||||||
|
/** Height of the inbox zone in world units. */
|
||||||
|
static readonly HEIGHT = 400;
|
||||||
|
|
||||||
|
constructor(textures: TextureManager, springs: SpringManager) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.textures = textures;
|
||||||
|
this.springs = springs;
|
||||||
|
|
||||||
|
// Position outside the typical content area
|
||||||
|
this.position.set(-500, -500);
|
||||||
|
|
||||||
|
// Dashed border rectangle
|
||||||
|
this.border = new Graphics();
|
||||||
|
this._drawBorder();
|
||||||
|
this.addChild(this.border);
|
||||||
|
|
||||||
|
// "Inbox" label at the top
|
||||||
|
const style = new TextStyle({
|
||||||
|
fontSize: 14,
|
||||||
|
fill: 0x888888,
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
fontWeight: '600',
|
||||||
|
});
|
||||||
|
this.labelText = new Text({ text: 'Inbox', style });
|
||||||
|
this.labelText.position.set(10, 8);
|
||||||
|
this.addChild(this.labelText);
|
||||||
|
|
||||||
|
// Container for media items (below the label)
|
||||||
|
this.mediaContainer = new Container();
|
||||||
|
this.mediaContainer.position.set(0, 32);
|
||||||
|
this.addChild(this.mediaContainer);
|
||||||
|
|
||||||
|
// Non-interactive by default — items inside can be dragged out
|
||||||
|
this.eventMode = 'passive';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw the dashed border rectangle. */
|
||||||
|
private _drawBorder(): void {
|
||||||
|
const g = this.border;
|
||||||
|
const w = InboxZone.WIDTH;
|
||||||
|
const h = InboxZone.HEIGHT;
|
||||||
|
const dashLen = 8;
|
||||||
|
const gapLen = 6;
|
||||||
|
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
// Background fill (very subtle)
|
||||||
|
g.rect(0, 0, w, h);
|
||||||
|
g.fill({ color: 0x222222, alpha: 0.2 });
|
||||||
|
|
||||||
|
// Dashed border — draw as individual line segments
|
||||||
|
g.setStrokeStyle({ width: 1, color: 0x666666, alpha: 0.3 });
|
||||||
|
|
||||||
|
// Top edge
|
||||||
|
this._drawDashedLine(g, 0, 0, w, 0, dashLen, gapLen);
|
||||||
|
// Right edge
|
||||||
|
this._drawDashedLine(g, w, 0, w, h, dashLen, gapLen);
|
||||||
|
// Bottom edge
|
||||||
|
this._drawDashedLine(g, w, h, 0, h, dashLen, gapLen);
|
||||||
|
// Left edge
|
||||||
|
this._drawDashedLine(g, 0, h, 0, 0, dashLen, gapLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw a dashed line between two points. */
|
||||||
|
private _drawDashedLine(
|
||||||
|
g: Graphics,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
dashLen: number,
|
||||||
|
gapLen: number,
|
||||||
|
): void {
|
||||||
|
const dx = x2 - x1;
|
||||||
|
const dy = y2 - y1;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const nx = dx / dist;
|
||||||
|
const ny = dy / dist;
|
||||||
|
let pos = 0;
|
||||||
|
let drawing = true;
|
||||||
|
|
||||||
|
while (pos < dist) {
|
||||||
|
const segLen = drawing ? dashLen : gapLen;
|
||||||
|
const end = Math.min(pos + segLen, dist);
|
||||||
|
|
||||||
|
if (drawing) {
|
||||||
|
g.moveTo(x1 + nx * pos, y1 + ny * pos);
|
||||||
|
g.lineTo(x1 + nx * end, y1 + ny * end);
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = end;
|
||||||
|
drawing = !drawing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add media items to the inbox with slide-in animation.
|
||||||
|
* Items are positioned with slight random offset and rotation.
|
||||||
|
*/
|
||||||
|
addMedia(items: { assetKey: string; w: number; h: number }[]): void {
|
||||||
|
const padding = 10;
|
||||||
|
const maxW = InboxZone.WIDTH - padding * 2;
|
||||||
|
const availH = InboxZone.HEIGHT - 40 - padding;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const { assetKey, w, h } = items[i];
|
||||||
|
|
||||||
|
// Scale to fit within inbox width
|
||||||
|
const scale = Math.min(maxW / w, 80 / h, 1);
|
||||||
|
const sw = w * scale;
|
||||||
|
const sh = h * scale;
|
||||||
|
|
||||||
|
const sprite = new Sprite(Texture.EMPTY);
|
||||||
|
sprite.width = sw;
|
||||||
|
sprite.height = sh;
|
||||||
|
|
||||||
|
// Load thumbnail texture
|
||||||
|
this.textures.load(assetKey, 'thumb').then((tex) => {
|
||||||
|
if (!sprite.destroyed) {
|
||||||
|
sprite.texture = tex;
|
||||||
|
sprite.width = sw;
|
||||||
|
sprite.height = sh;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Random offset and rotation
|
||||||
|
const offsetX = (Math.random() - 0.5) * 40;
|
||||||
|
const offsetY = (Math.random() - 0.5) * 40;
|
||||||
|
const rotation = ((Math.random() - 0.5) * 6 * Math.PI) / 180; // ±3 degrees
|
||||||
|
|
||||||
|
const targetX = padding + offsetX + (maxW - sw) / 2;
|
||||||
|
const targetY = (i * (sh + 10)) % availH + offsetY;
|
||||||
|
|
||||||
|
sprite.position.set(-InboxZone.WIDTH, targetY); // Start off-screen left
|
||||||
|
sprite.rotation = rotation;
|
||||||
|
sprite.alpha = 0;
|
||||||
|
|
||||||
|
this.mediaContainer.addChild(sprite);
|
||||||
|
|
||||||
|
// Spring slide-in animation from left
|
||||||
|
const slideSpring = new Spring(-InboxZone.WIDTH, targetX, PRESETS.bounce);
|
||||||
|
slideSpring.onUpdate = (v) => {
|
||||||
|
if (!sprite.destroyed) sprite.x = v;
|
||||||
|
};
|
||||||
|
this.springs.add(slideSpring);
|
||||||
|
|
||||||
|
// Fade in
|
||||||
|
const fadeSpring = new Spring(0, 1, PRESETS.gentle);
|
||||||
|
fadeSpring.onUpdate = (v) => {
|
||||||
|
if (!sprite.destroyed) sprite.alpha = v;
|
||||||
|
};
|
||||||
|
this.springs.add(fadeSpring);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all media items from the inbox. */
|
||||||
|
clear(): void {
|
||||||
|
while (this.mediaContainer.children.length > 0) {
|
||||||
|
const child = this.mediaContainer.children[0];
|
||||||
|
this.mediaContainer.removeChild(child);
|
||||||
|
child.destroy({ children: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of items currently in the inbox. */
|
||||||
|
get count(): number {
|
||||||
|
return this.mediaContainer.children.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* MattermostImport — modal dialog for importing media from Mattermost.
|
||||||
|
*
|
||||||
|
* Allows pulling images from a Mattermost thread/channel URL and managing
|
||||||
|
* linked channels for auto-sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
interface MattermostImportProps {
|
||||||
|
boardId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onMediaArrived?: (assets: { assetKey: string; w: number; h: number }[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkedChannel {
|
||||||
|
id: string;
|
||||||
|
channelName: string;
|
||||||
|
channelId: string;
|
||||||
|
linkedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MattermostImport({ boardId, onClose, onMediaArrived }: MattermostImportProps) {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [pulling, setPulling] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
const [linkedChannels, setLinkedChannels] = useState<LinkedChannel[]>([]);
|
||||||
|
const [loadingChannels, setLoadingChannels] = useState(false);
|
||||||
|
const [linkUrl, setLinkUrl] = useState('');
|
||||||
|
const [linking, setLinking] = useState(false);
|
||||||
|
|
||||||
|
// Load linked channels on mount
|
||||||
|
const loadLinkedChannels = useCallback(async () => {
|
||||||
|
setLoadingChannels(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/api/boards/${boardId}/mm-links`);
|
||||||
|
setLinkedChannels(res.data?.links || []);
|
||||||
|
} catch {
|
||||||
|
// Endpoint may not exist yet — silently ignore
|
||||||
|
setLinkedChannels([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingChannels(false);
|
||||||
|
}
|
||||||
|
}, [boardId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLinkedChannels();
|
||||||
|
}, [loadLinkedChannels]);
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Pull images from URL
|
||||||
|
const handlePull = async () => {
|
||||||
|
if (!url.trim()) return;
|
||||||
|
setPulling(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/api/boards/${boardId}/mm-pull`, { url: url.trim() });
|
||||||
|
const assets = res.data?.assets || [];
|
||||||
|
const count = assets.length;
|
||||||
|
setSuccess(`Pulled ${count} image${count !== 1 ? 's' : ''}`);
|
||||||
|
setUrl('');
|
||||||
|
|
||||||
|
if (count > 0 && onMediaArrived) {
|
||||||
|
onMediaArrived(assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-close after brief delay on success
|
||||||
|
if (count > 0) {
|
||||||
|
setTimeout(onClose, 800);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to pull images');
|
||||||
|
} finally {
|
||||||
|
setPulling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Link a channel
|
||||||
|
const handleLink = async () => {
|
||||||
|
if (!linkUrl.trim()) return;
|
||||||
|
setLinking(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/api/boards/${boardId}/mm-links`, { url: linkUrl.trim() });
|
||||||
|
setLinkUrl('');
|
||||||
|
loadLinkedChannels();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to link channel');
|
||||||
|
} finally {
|
||||||
|
setLinking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unlink a channel
|
||||||
|
const handleUnlink = async (linkId: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/boards/${boardId}/mm-links/${linkId}`);
|
||||||
|
loadLinkedChannels();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to unlink channel');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 2000,
|
||||||
|
background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(6px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: '#141414', border: '1px solid #222', borderRadius: '16px',
|
||||||
|
width: '100%', maxWidth: '480px', maxHeight: '85vh',
|
||||||
|
overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||||
|
boxShadow: '0 24px 64px rgba(0,0,0,0.6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '20px 24px 16px', borderBottom: '1px solid #1e1e1e', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<h2 style={{
|
||||||
|
margin: 0, fontSize: '16px', fontWeight: 600,
|
||||||
|
color: '#e0e0e0', letterSpacing: '-0.3px',
|
||||||
|
}}>
|
||||||
|
Import from Mattermost
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: '1px solid #333', borderRadius: '6px',
|
||||||
|
color: '#888', padding: '4px 12px', cursor: 'pointer', fontSize: '11px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
ESC
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px 24px' }}>
|
||||||
|
{/* Error / Success */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px', background: 'rgba(255,80,80,0.1)',
|
||||||
|
border: '1px solid rgba(255,80,80,0.2)', borderRadius: '8px',
|
||||||
|
color: '#ff6b6b', fontSize: '12px', marginBottom: '12px',
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px', background: 'rgba(74,222,128,0.1)',
|
||||||
|
border: '1px solid rgba(74,222,128,0.2)', borderRadius: '8px',
|
||||||
|
color: '#4ade80', fontSize: '12px', marginBottom: '12px',
|
||||||
|
}}>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pull images section */}
|
||||||
|
<SectionLabel text="Pull Images from Thread" />
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handlePull(); }}
|
||||||
|
placeholder="https://chat.metalfinger.xyz/team/pl/..."
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '8px 12px', background: '#1a1a1a',
|
||||||
|
border: '1px solid #333', borderRadius: '8px',
|
||||||
|
color: '#e0e0e0', fontSize: '13px', outline: 'none',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = '#333'; }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handlePull}
|
||||||
|
disabled={pulling || !url.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: pulling ? '#333' : 'linear-gradient(135deg, #4a9eff, #3d7dd8)',
|
||||||
|
border: 'none', borderRadius: '8px',
|
||||||
|
color: '#fff', fontSize: '12px', fontWeight: 600,
|
||||||
|
cursor: pulling ? 'wait' : 'pointer',
|
||||||
|
opacity: !url.trim() ? 0.5 : 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pulling ? 'Pulling...' : 'Pull Images'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Linked channels section */}
|
||||||
|
<SectionLabel text="Linked Channels" />
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
{loadingChannels ? (
|
||||||
|
<div style={{ fontSize: '12px', color: '#555', padding: '8px 0' }}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : linkedChannels.length === 0 ? (
|
||||||
|
<div style={{ fontSize: '12px', color: '#555', padding: '8px 0' }}>
|
||||||
|
No linked channels. Link one below for auto-sync.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
{linkedChannels.map((ch) => (
|
||||||
|
<div
|
||||||
|
key={ch.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '8px 12px', background: '#1a1a1a',
|
||||||
|
border: '1px solid #2a2a2a', borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '13px', color: '#ccc', fontWeight: 500 }}>
|
||||||
|
{ch.channelName || ch.channelId}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '10px', color: '#555', marginTop: '2px' }}>
|
||||||
|
Linked {new Date(ch.linkedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnlink(ch.id)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px', background: 'transparent',
|
||||||
|
border: '1px solid #333', borderRadius: '6px',
|
||||||
|
color: '#888', fontSize: '11px', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#ff6b6b';
|
||||||
|
e.currentTarget.style.color = '#ff6b6b';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#333';
|
||||||
|
e.currentTarget.style.color = '#888';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unlink
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link new channel */}
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={linkUrl}
|
||||||
|
onChange={(e) => setLinkUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleLink(); }}
|
||||||
|
placeholder="Channel URL to link..."
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '8px 12px', background: '#1a1a1a',
|
||||||
|
border: '1px solid #333', borderRadius: '8px',
|
||||||
|
color: '#e0e0e0', fontSize: '13px', outline: 'none',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; }}
|
||||||
|
onBlur={(e) => { e.currentTarget.style.borderColor = '#333'; }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleLink}
|
||||||
|
disabled={linking || !linkUrl.trim()}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px', background: '#1a1a1a',
|
||||||
|
border: '1px solid #333', borderRadius: '8px',
|
||||||
|
color: '#ccc', fontSize: '12px', fontWeight: 500,
|
||||||
|
cursor: linking ? 'wait' : 'pointer',
|
||||||
|
opacity: !linkUrl.trim() ? 0.5 : 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (linkUrl.trim()) {
|
||||||
|
e.currentTarget.style.borderColor = '#4a9eff';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = '#333';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{linking ? 'Linking...' : 'Link Channel'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
fontSize: '10px', fontWeight: 700, color: '#4a9eff', letterSpacing: '0.8px',
|
||||||
|
textTransform: 'uppercase', padding: '0 0 8px', marginBottom: '4px',
|
||||||
|
}}>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ interface ToolbarProps {
|
|||||||
onToggleLayers?: () => void;
|
onToggleLayers?: () => void;
|
||||||
showLayers?: boolean;
|
showLayers?: boolean;
|
||||||
onToggleHelp?: () => void;
|
onToggleHelp?: () => void;
|
||||||
|
onMmImport?: () => void;
|
||||||
boardName?: string;
|
boardName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ export default function Toolbar({
|
|||||||
onToggleLayers,
|
onToggleLayers,
|
||||||
showLayers,
|
showLayers,
|
||||||
onToggleHelp,
|
onToggleHelp,
|
||||||
|
onMmImport,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
const showStroke = activeTool === ToolType.PEN;
|
const showStroke = activeTool === ToolType.PEN;
|
||||||
const showFontSize = activeTool === ToolType.TEXT;
|
const showFontSize = activeTool === ToolType.TEXT;
|
||||||
@@ -281,6 +283,16 @@ export default function Toolbar({
|
|||||||
</ActionBtn>
|
</ActionBtn>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mattermost import */}
|
||||||
|
{onMmImport && (
|
||||||
|
<ActionBtn onClick={onMmImport} title="Import from Mattermost">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||||
|
<path d="M2 10V4a2 2 0 012-2h6a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2z" />
|
||||||
|
<path d="M5 7h4M7 5v4" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</ActionBtn>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import UserCursors from '../components/UserCursors';
|
|||||||
import ContextMenu from '../components/ContextMenu';
|
import ContextMenu from '../components/ContextMenu';
|
||||||
import LayerPanel from '../components/LayerPanel';
|
import LayerPanel from '../components/LayerPanel';
|
||||||
import ShortcutsHelp from '../components/ShortcutsHelp';
|
import ShortcutsHelp from '../components/ShortcutsHelp';
|
||||||
|
import MattermostImport from '../components/MattermostImport';
|
||||||
|
import { InboxZone } from '../canvas/InboxZone';
|
||||||
|
|
||||||
// Pre-sort shortcuts by specificity (most modifiers first) for correct matching
|
// Pre-sort shortcuts by specificity (most modifiers first) for correct matching
|
||||||
const sortedShortcuts = sortBySpecificity(shortcutDefs);
|
const sortedShortcuts = sortBySpecificity(shortcutDefs);
|
||||||
@@ -82,9 +84,11 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const [showLayers, setShowLayers] = useState(false);
|
const [showLayers, setShowLayers] = useState(false);
|
||||||
const [showGrid, setShowGrid] = useState(false);
|
const [showGrid, setShowGrid] = useState(false);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
const [showMmImport, setShowMmImport] = useState(false);
|
||||||
const [layerList, setLayerList] = useState<any[]>([]);
|
const [layerList, setLayerList] = useState<any[]>([]);
|
||||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||||
const clipboardRef = useRef<SceneItem[]>([]);
|
const clipboardRef = useRef<SceneItem[]>([]);
|
||||||
|
const inboxZoneRef = useRef<InboxZone | null>(null);
|
||||||
|
|
||||||
const resolvedBoardId = boardId || boardData?.board?.id;
|
const resolvedBoardId = boardId || boardData?.board?.id;
|
||||||
|
|
||||||
@@ -238,6 +242,11 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
// Create UndoManager
|
// Create UndoManager
|
||||||
undoRef.current = new UndoManager(scene);
|
undoRef.current = new UndoManager(scene);
|
||||||
|
|
||||||
|
// Create InboxZone and add to viewport
|
||||||
|
const inboxZone = new InboxZone(scene.textures, scene.springs);
|
||||||
|
viewport.addChild(inboxZone);
|
||||||
|
inboxZoneRef.current = inboxZone;
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const socket = connectSocket();
|
const socket = connectSocket();
|
||||||
|
|
||||||
@@ -270,6 +279,15 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for auto-synced media arriving via socket
|
||||||
|
socket.on('board:media-arrived', (data: any) => {
|
||||||
|
const assets = data?.assets;
|
||||||
|
if (Array.isArray(assets) && assets.length > 0 && inboxZoneRef.current) {
|
||||||
|
inboxZoneRef.current.addMedia(assets);
|
||||||
|
showToast(`${assets.length} image${assets.length !== 1 ? 's' : ''} arrived in Inbox`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Cursor tracking: listen on viewport for pointermove
|
// Cursor tracking: listen on viewport for pointermove
|
||||||
const onPointerMove = (e: any) => {
|
const onPointerMove = (e: any) => {
|
||||||
const world = viewport.toWorld(e.global.x, e.global.y);
|
const world = viewport.toWorld(e.global.x, e.global.y);
|
||||||
@@ -317,6 +335,11 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
selectionRef.current?.destroy();
|
selectionRef.current?.destroy();
|
||||||
selectionRef.current = null;
|
selectionRef.current = null;
|
||||||
|
if (inboxZoneRef.current) {
|
||||||
|
inboxZoneRef.current.clear();
|
||||||
|
inboxZoneRef.current.destroy({ children: true });
|
||||||
|
inboxZoneRef.current = null;
|
||||||
|
}
|
||||||
syncCleanupRef.current?.();
|
syncCleanupRef.current?.();
|
||||||
dropCleanupRef.current?.();
|
dropCleanupRef.current?.();
|
||||||
pasteCleanupRef.current?.();
|
pasteCleanupRef.current?.();
|
||||||
@@ -896,6 +919,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
onToggleLayers={() => setShowLayers((v) => !v)}
|
onToggleLayers={() => setShowLayers((v) => !v)}
|
||||||
showLayers={showLayers}
|
showLayers={showLayers}
|
||||||
onToggleHelp={() => setShowHelp((v) => !v)}
|
onToggleHelp={() => setShowHelp((v) => !v)}
|
||||||
|
onMmImport={() => setShowMmImport(true)}
|
||||||
boardName={board?.name}
|
boardName={board?.name}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1063,6 +1087,19 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
{showHelp && (
|
{showHelp && (
|
||||||
<ShortcutsHelp shortcuts={shortcutDefs} onClose={() => setShowHelp(false)} />
|
<ShortcutsHelp shortcuts={shortcutDefs} onClose={() => setShowHelp(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Mattermost import modal */}
|
||||||
|
{showMmImport && resolvedBoardId && (
|
||||||
|
<MattermostImport
|
||||||
|
boardId={resolvedBoardId}
|
||||||
|
onClose={() => setShowMmImport(false)}
|
||||||
|
onMediaArrived={(assets) => {
|
||||||
|
if (inboxZoneRef.current) {
|
||||||
|
inboxZoneRef.current.addMedia(assets);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user