← 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>
  );
}