← back to Norma
components/email-analyzer/LayoutGrid.tsx
313 lines
'use client';
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import GridLayout, { Layout, WidthProvider } from 'react-grid-layout';
import { Columns2, Columns3, Columns4, RectangleHorizontal, RotateCcw, PanelLeftRightDashed } from 'lucide-react';
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
const ResponsiveGridLayout = WidthProvider(GridLayout);
export interface PanelLayoutEntry {
i: string; // panel id
x: number; // column index
y: number; // row index
w: number; // width in columns
h: number; // height in row units
collapsed?: boolean;
}
export interface LayoutState {
columns: 1 | 2 | 3 | 4;
panels: PanelLayoutEntry[];
}
export type PanelRenderer = (
collapsed: boolean,
setCollapsed: (next: boolean) => void,
) => ReactNode;
interface LayoutGridProps {
layout: LayoutState;
onLayoutChange: (next: LayoutState) => void;
panelRenderers: Record<string, PanelRenderer>;
onReset?: () => void;
dragHandleClassName?: string;
rowHeight?: number;
}
const COLUMN_OPTIONS: { n: 1 | 2 | 3 | 4; Icon: typeof Columns2; label: string }[] = [
{ n: 1, Icon: RectangleHorizontal, label: '1 column' },
{ n: 2, Icon: Columns2, label: '2 columns' },
{ n: 3, Icon: Columns3, label: '3 columns' },
{ n: 4, Icon: Columns4, label: '4 columns' },
];
export default function LayoutGrid({
layout,
onLayoutChange,
panelRenderers,
onReset,
dragHandleClassName = 'panel-drag-handle',
rowHeight = 32,
}: LayoutGridProps) {
const prevColumnsRef = useRef<number>(layout.columns);
// ── Focus Mode (master/detail 2-pane) ──────────────────────────────────
// Persists per-browser. Hidden behind a toolbar toggle so the freeform
// grid stays the default for power users.
const FOCUS_KEY = 'email-analyzer-focus-v1';
const [focusMode, setFocusMode] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem(FOCUS_KEY) === '1';
});
function toggleFocus() {
const next = !focusMode;
setFocusMode(next);
if (typeof window !== 'undefined') {
window.localStorage.setItem(FOCUS_KEY, next ? '1' : '0');
}
}
// Left pane = the "source of work" (where you pick / shape an email).
// Right pane = "context" (everything that reacts to or informs the left).
const FOCUS_LEFT: string[] = ['original', 'generated', 'versions', 'chat', 'sdccEmails', 'saved', 'search'];
const FOCUS_RIGHT: string[] = ['score', 'stats', 'proscons', 'suggestions', 'references', 'subjects', 'controls'];
// Only render panels that have a renderer (skips null panels like Generated when empty).
const visiblePanels = useMemo(
() => layout.panels.filter((p) => panelRenderers[p.i] !== undefined),
[layout.panels, panelRenderers],
);
const gridLayout: Layout[] = visiblePanels.map((p) => ({
i: p.i,
x: Math.min(p.x, layout.columns - 1),
y: p.y,
w: Math.min(p.w, layout.columns),
h: p.collapsed ? 1 : Math.max(p.h, 2),
minH: 1,
}));
function handleColumnChange(n: 1 | 2 | 3 | 4) {
if (n === layout.columns) return;
// Redistribute ALL panels across the new column count, preserving their
// y-order. Previously we only clamped x/w which left panels stacked in
// column 0 because most had w=1,x=0 — the layout change was a no-op visually.
//
// Sort by (y, x) to preserve the user's existing top-to-bottom order,
// then flow each panel into the next grid slot: x = i % n, y = floor(i/n).
// Width defaults to 1 (single column); if panel previously spanned the whole
// grid (w === layout.columns), keep it full-width in the new layout too.
const ordered = [...layout.panels]
.map((p, idx) => ({ p, idx, sortY: p.y, sortX: p.x }))
.sort((a, b) => (a.sortY - b.sortY) || (a.sortX - b.sortX));
const redistributed: PanelLayoutEntry[] = ordered.map(({ p }, i) => {
const wasFullWidth = p.w >= layout.columns;
const w = wasFullWidth ? n : 1;
if (wasFullWidth) {
// Full-width panels keep their own row
return { ...p, x: 0, y: i, w };
}
return { ...p, x: i % n, y: Math.floor(i / n), w: 1 };
});
onLayoutChange({ columns: n, panels: redistributed });
}
function handleGridLayoutChange(next: Layout[]) {
const byId = new Map(next.map((n) => [n.i, n]));
const updated: PanelLayoutEntry[] = layout.panels.map((p) => {
const g = byId.get(p.i);
if (!g) return p;
return {
...p,
x: g.x,
y: g.y,
w: g.w,
// Preserve h only when expanded — collapsed h is always 1 (handled at render time)
h: p.collapsed ? p.h : g.h,
};
});
onLayoutChange({ ...layout, panels: updated });
}
function toggleCollapsed(panelId: string, collapsed: boolean) {
const updated = layout.panels.map((p) =>
p.i === panelId ? { ...p, collapsed } : p,
);
onLayoutChange({ ...layout, panels: updated });
}
// When column count changes, give the grid a beat to remeasure.
useEffect(() => {
if (prevColumnsRef.current !== layout.columns) {
prevColumnsRef.current = layout.columns;
window.dispatchEvent(new Event('resize'));
}
}, [layout.columns]);
return (
<div>
{/* ── Toolbar ─────────────────────────────────────────────────────── */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
marginBottom: 12,
padding: '6px 10px',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
flexWrap: 'wrap',
}}
>
<span
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: 0.6,
marginRight: 4,
}}
>
Layout
</span>
<div style={{ display: 'flex', gap: 2 }} role="group" aria-label="Column count">
{COLUMN_OPTIONS.map(({ n, Icon, label }) => {
const active = layout.columns === n;
return (
<button
key={n}
type="button"
onClick={() => handleColumnChange(n)}
aria-label={label}
aria-pressed={active}
title={label}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 30,
height: 28,
borderRadius: 'var(--radius-sm)',
border: `1px solid ${active ? 'var(--color-primary)' : 'var(--color-border)'}`,
background: active ? 'rgba(16,185,129,0.12)' : 'transparent',
color: active ? 'var(--color-primary)' : 'var(--color-text-secondary)',
cursor: 'pointer',
padding: 0,
transition: 'all var(--transition)',
}}
>
<Icon size={14} />
</button>
);
})}
</div>
<button
type="button"
onClick={toggleFocus}
aria-label="Toggle focus mode"
aria-pressed={focusMode}
title={focusMode ? 'Exit focus mode' : 'Focus mode (left=source, right=context)'}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
height: 28,
padding: '0 10px',
borderRadius: 'var(--radius-sm)',
border: `1px solid ${focusMode ? 'var(--color-primary)' : 'var(--color-border)'}`,
background: focusMode ? 'rgba(16,185,129,0.12)' : 'transparent',
color: focusMode ? 'var(--color-primary)' : 'var(--color-text-secondary)',
cursor: 'pointer',
fontSize: 11,
fontWeight: 600,
letterSpacing: 0.4,
textTransform: 'uppercase',
transition: 'all var(--transition)',
}}
>
<PanelLeftRightDashed size={13} /> Focus
</button>
<span style={{ flex: 1 }} />
{onReset ? (
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onReset}
style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12 }}
title="Restore default layout"
>
<RotateCcw size={12} /> Reset layout
</button>
) : null}
</div>
{/* ── Focus Mode (master/detail) ─────────────────────────────────── */}
{focusMode ? (
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1.1fr) minmax(0,1fr)',
gap: 14,
alignItems: 'flex-start',
}}
>
{[FOCUS_LEFT, FOCUS_RIGHT].map((ids, paneIdx) => (
<div
key={paneIdx}
style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}
>
{ids
.filter((id) => panelRenderers[id])
.map((id) => {
const panel = layout.panels.find((p) => p.i === id);
const collapsed = panel?.collapsed ?? false;
const setCollapsed = (next: boolean) => toggleCollapsed(id, next);
return (
<div key={id} data-panel-id={id}>
{panelRenderers[id](collapsed, setCollapsed)}
</div>
);
})}
</div>
))}
</div>
) : (
/* ── Grid (default) ─────────────────────────────────────────────── */
<ResponsiveGridLayout
className="norma-panel-grid"
layout={gridLayout}
cols={layout.columns}
rowHeight={rowHeight}
margin={[14, 14]}
containerPadding={[0, 0]}
draggableHandle={`.${dragHandleClassName}`}
onLayoutChange={handleGridLayoutChange}
// "vertical" packs upward by row first → fills left-to-right across each
// row before wrapping down. "horizontal" (the previous setting) packed
// column-first, which made layout switches look stuck in the leftmost
// columns regardless of the row-major positions we hand it.
compactType="vertical"
preventCollision={false}
isBounded={false}
useCSSTransforms
>
{visiblePanels.map((p) => {
const collapsed = p.collapsed ?? false;
const setCollapsed = (next: boolean) => toggleCollapsed(p.i, next);
return (
<div key={p.i} data-panel-id={p.i}>
{panelRenderers[p.i](collapsed, setCollapsed)}
</div>
);
})}
</ResponsiveGridLayout>
)}
</div>
);
}