From 21b2a433c7a2f3b5e93f247e23a6676f5a1eab3f Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Mon, 9 Mar 2026 23:36:04 +0530 Subject: [PATCH] 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. --- frontend/src/canvas/InboxZone.ts | 196 +++++++++++ frontend/src/components/MattermostImport.tsx | 321 +++++++++++++++++++ frontend/src/components/Toolbar.tsx | 12 + frontend/src/pages/Editor.tsx | 37 +++ 4 files changed, 566 insertions(+) create mode 100644 frontend/src/canvas/InboxZone.ts create mode 100644 frontend/src/components/MattermostImport.tsx diff --git a/frontend/src/canvas/InboxZone.ts b/frontend/src/canvas/InboxZone.ts new file mode 100644 index 0000000..732368d --- /dev/null +++ b/frontend/src/canvas/InboxZone.ts @@ -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; + } +} diff --git a/frontend/src/components/MattermostImport.tsx b/frontend/src/components/MattermostImport.tsx new file mode 100644 index 0000000..d39c900 --- /dev/null +++ b/frontend/src/components/MattermostImport.tsx @@ -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([]); + 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 ( +
+
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 */} +
+

+ Import from Mattermost +

+ +
+ + {/* Body */} +
+ {/* Error / Success */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* Pull images section */} + +
+ 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'; }} + /> + +
+ + {/* Linked channels section */} + +
+ {loadingChannels ? ( +
+ Loading... +
+ ) : linkedChannels.length === 0 ? ( +
+ No linked channels. Link one below for auto-sync. +
+ ) : ( +
+ {linkedChannels.map((ch) => ( +
+
+
+ {ch.channelName || ch.channelId} +
+
+ Linked {new Date(ch.linkedAt).toLocaleDateString()} +
+
+ +
+ ))} +
+ )} +
+ + {/* Link new channel */} +
+ 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'; }} + /> + +
+
+
+
+ ); +} + +function SectionLabel({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +} diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 7214d38..4e8bf98 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -30,6 +30,7 @@ interface ToolbarProps { onToggleLayers?: () => void; showLayers?: boolean; onToggleHelp?: () => void; + onMmImport?: () => void; boardName?: string; } @@ -142,6 +143,7 @@ export default function Toolbar({ onToggleLayers, showLayers, onToggleHelp, + onMmImport, }: ToolbarProps) { const showStroke = activeTool === ToolType.PEN; const showFontSize = activeTool === ToolType.TEXT; @@ -281,6 +283,16 @@ export default function Toolbar({ )} + {/* Mattermost import */} + {onMmImport && ( + + + + + + + )} + {/* Spacer */}
diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 10451d5..9539b7c 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -21,6 +21,8 @@ import UserCursors from '../components/UserCursors'; import ContextMenu from '../components/ContextMenu'; import LayerPanel from '../components/LayerPanel'; 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 const sortedShortcuts = sortBySpecificity(shortcutDefs); @@ -82,9 +84,11 @@ export default function Editor({ isPublicView }: EditorProps) { const [showLayers, setShowLayers] = useState(false); const [showGrid, setShowGrid] = useState(false); const [showHelp, setShowHelp] = useState(false); + const [showMmImport, setShowMmImport] = useState(false); const [layerList, setLayerList] = useState([]); const [selectedLayerIds, setSelectedLayerIds] = useState([]); const clipboardRef = useRef([]); + const inboxZoneRef = useRef(null); const resolvedBoardId = boardId || boardData?.board?.id; @@ -238,6 +242,11 @@ export default function Editor({ isPublicView }: EditorProps) { // Create UndoManager 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) { 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 const onPointerMove = (e: any) => { const world = viewport.toWorld(e.global.x, e.global.y); @@ -317,6 +335,11 @@ export default function Editor({ isPublicView }: EditorProps) { clearTimeout(timer); selectionRef.current?.destroy(); selectionRef.current = null; + if (inboxZoneRef.current) { + inboxZoneRef.current.clear(); + inboxZoneRef.current.destroy({ children: true }); + inboxZoneRef.current = null; + } syncCleanupRef.current?.(); dropCleanupRef.current?.(); pasteCleanupRef.current?.(); @@ -896,6 +919,7 @@ export default function Editor({ isPublicView }: EditorProps) { onToggleLayers={() => setShowLayers((v) => !v)} showLayers={showLayers} onToggleHelp={() => setShowHelp((v) => !v)} + onMmImport={() => setShowMmImport(true)} boardName={board?.name} /> @@ -1063,6 +1087,19 @@ export default function Editor({ isPublicView }: EditorProps) { {showHelp && ( setShowHelp(false)} /> )} + + {/* Mattermost import modal */} + {showMmImport && resolvedBoardId && ( + setShowMmImport(false)} + onMediaArrived={(assets) => { + if (inboxZoneRef.current) { + inboxZoneRef.current.addMedia(assets); + } + }} + /> + )}
); }