# Phase 4: Components Extraction

## Goal

Split the monolithic ImageViewer.tsx into separate component files.

## Files to Create

### components/ImageToolbar.tsx

Floating toolbar with zoom/rotate/flip controls.

```typescript
'use client';

/**
 * ImageToolbar - Floating toolbar for image controls
 */

import { useCallback } from 'react';
import { Button, cn } from '@djangocfg/ui-core';
import {
  ZoomIn,
  ZoomOut,
  RotateCw,
  FlipHorizontal,
  FlipVertical,
  Maximize2,
  Expand,
} from 'lucide-react';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '../../components/dropdown-menu';
import { useControls } from 'react-zoom-pan-pinch';
import { ZOOM_PRESETS, MIN_ZOOM, MAX_ZOOM } from '../utils';
import type { ImageToolbarProps } from '../types';

export function ImageToolbar({
  scale,
  onExpand,
  onRotate,
  onFlipH,
  onFlipV,
  flipH,
  flipV,
  inDialog = false,
}: ImageToolbarProps) {
  const { zoomIn, zoomOut, resetTransform, centerView, setTransform } = useControls();

  // Calculate zoom label
  const zoomLabel = `${Math.round(scale * 100)}%`;

  // Handle zoom preset selection
  const handleZoomPreset = useCallback(
    (value: number) => {
      if (value === -1) {
        // Fit to view
        resetTransform();
      } else {
        setTransform(0, 0, value);
        centerView(value);
      }
    },
    [resetTransform, setTransform, centerView]
  );

  return (
    <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1 bg-background/95 backdrop-blur-sm border rounded-lg p-1.5 shadow-lg">
      {/* Zoom Out */}
      <Button
        variant="ghost"
        size="icon"
        className="h-8 w-8"
        onClick={() => zoomOut()}
        disabled={scale <= MIN_ZOOM}
        title="Zoom out"
      >
        <ZoomOut className="h-4 w-4" />
      </Button>

      {/* Zoom Dropdown */}
      <DropdownMenu>
        <DropdownMenuTrigger asChild>
          <Button variant="ghost" size="sm" className="h-8 min-w-16 px-2 font-mono text-xs">
            {zoomLabel}
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="center">
          {ZOOM_PRESETS.map((preset) => (
            <DropdownMenuItem
              key={preset.value}
              onClick={() => handleZoomPreset(preset.value)}
            >
              {preset.label}
            </DropdownMenuItem>
          ))}
        </DropdownMenuContent>
      </DropdownMenu>

      {/* Zoom In */}
      <Button
        variant="ghost"
        size="icon"
        className="h-8 w-8"
        onClick={() => zoomIn()}
        disabled={scale >= MAX_ZOOM}
        title="Zoom in"
      >
        <ZoomIn className="h-4 w-4" />
      </Button>

      <div className="w-px h-6 bg-border mx-1" />

      {/* Fit to View */}
      <Button
        variant="ghost"
        size="icon"
        className="h-8 w-8"
        onClick={() => resetTransform()}
        title="Fit to view"
      >
        <Maximize2 className="h-4 w-4" />
      </Button>

      {/* Rotate */}
      <Button
        variant="ghost"
        size="icon"
        className="h-8 w-8"
        onClick={onRotate}
        title="Rotate 90°"
      >
        <RotateCw className="h-4 w-4" />
      </Button>

      {/* Flip Horizontal */}
      <Button
        variant="ghost"
        size="icon"
        className={cn('h-8 w-8', flipH && 'bg-muted text-primary')}
        onClick={onFlipH}
        title="Flip horizontal"
      >
        <FlipHorizontal className="h-4 w-4" />
      </Button>

      {/* Flip Vertical */}
      <Button
        variant="ghost"
        size="icon"
        className={cn('h-8 w-8', flipV && 'bg-muted text-primary')}
        onClick={onFlipV}
        title="Flip vertical"
      >
        <FlipVertical className="h-4 w-4" />
      </Button>

      {/* Expand (hidden in dialog) */}
      {!inDialog && (
        <>
          <div className="w-px h-6 bg-border mx-1" />
          <Button
            variant="ghost"
            size="icon"
            className="h-8 w-8"
            onClick={onExpand}
            title="Expand fullscreen"
          >
            <Expand className="h-4 w-4" />
          </Button>
        </>
      )}
    </div>
  );
}
```

### components/ImageInfo.tsx

Displays image dimensions badge.

```typescript
'use client';

/**
 * ImageInfo - Displays image dimensions
 */

import { useEffect, useState } from 'react';
import { useImageCache } from '../../../stores/mediaCache';
import type { ImageInfoProps } from '../types';

export function ImageInfo({ src, contentKey }: ImageInfoProps) {
  const { getDimensions, cacheDimensions } = useImageCache();

  const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(
    () => getDimensions(contentKey)
  );

  useEffect(() => {
    // Already have dimensions
    if (dimensions) return;

    // Load image to get dimensions
    const img = new Image();
    img.onload = () => {
      const dims = { width: img.naturalWidth, height: img.naturalHeight };
      cacheDimensions(contentKey, dims);
      setDimensions(dims);
    };
    img.src = src;
  }, [src, contentKey, dimensions, cacheDimensions]);

  if (!dimensions) return null;

  return (
    <div className="absolute top-2 right-2 z-10 px-2 py-1 text-xs font-mono bg-background/80 backdrop-blur-sm border rounded text-muted-foreground">
      {dimensions.width} × {dimensions.height}
    </div>
  );
}
```

### components/ImageViewer.tsx

Main component, now simplified.

```typescript
'use client';

/**
 * ImageViewer - Image display with zoom/pan/rotate capabilities
 */

import { useRef, useEffect, useCallback, useState } from 'react';
import { TransformWrapper, TransformComponent, useControls } from 'react-zoom-pan-pinch';
import { Dialog, DialogContent, Alert, AlertDescription, cn } from '@djangocfg/ui-core';
import { AlertCircle, ImageIcon } from 'lucide-react';

import { ImageToolbar } from './ImageToolbar';
import { ImageInfo } from './ImageInfo';
import { useImageTransform, useImageLoading } from '../hooks';
import { MIN_ZOOM, MAX_ZOOM } from '../utils';
import type { ImageViewerProps } from '../types';

// Controls wrapper for accessing zoom state
function ImageViewerContent({
  file,
  src,
  lqip,
  isLoading,
  isReady,
  contentKey,
  isLarge,
  transform,
  transformStyle,
  rotate,
  flipH,
  flipV,
  onExpand,
  inDialog,
}: /* props type */) {
  const { zoomIn, zoomOut, resetTransform } = useControls();
  const [scale, setScale] = useState(1);

  // Keyboard shortcuts
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === '+' || e.key === '=') zoomIn();
      if (e.key === '-') zoomOut();
      if (e.key === '0') resetTransform();
      if (e.key === 'r' || e.key === 'R') rotate();
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [zoomIn, zoomOut, resetTransform, rotate]);

  return (
    <>
      <ImageToolbar
        scale={scale}
        onExpand={onExpand}
        onRotate={rotate}
        onFlipH={flipH}
        onFlipV={flipV}
        flipH={transform.flipH}
        flipV={transform.flipV}
        inDialog={inDialog}
      />

      <ImageInfo src={src} contentKey={contentKey} />

      <TransformComponent wrapperClass="!w-full !h-full" contentClass="!w-full !h-full">
        <div className="relative w-full h-full flex items-center justify-center">
          {/* LQIP placeholder */}
          {isLarge && lqip && !isReady && (
            <img
              src={lqip}
              alt=""
              aria-hidden="true"
              className="absolute inset-0 w-full h-full object-contain blur-lg scale-105"
            />
          )}

          {/* Main image */}
          <img
            src={src}
            alt={file.name}
            className={cn(
              'max-w-full max-h-full object-contain transition-opacity',
              isLoading && 'opacity-50'
            )}
            style={{ transform: transformStyle }}
            draggable={false}
          />
        </div>
      </TransformComponent>
    </>
  );
}

export function ImageViewer({ file, content, inDialog = false }: ImageViewerProps) {
  const [dialogOpen, setDialogOpen] = useState(false);

  // Loading state
  const {
    src,
    lqip,
    isLoading,
    isReady,
    error,
    contentKey,
    isOversized,
    isLarge,
  } = useImageLoading({ content, mimeType: file.mimeType });

  // Transform state
  const { transform, rotate, flipH, flipV, transformStyle } = useImageTransform({
    resetKey: file.path,
  });

  // Error state
  if (error || isOversized) {
    return (
      <Alert variant="destructive">
        <AlertCircle className="h-4 w-4" />
        <AlertDescription>{error || 'Image too large to display'}</AlertDescription>
      </Alert>
    );
  }

  // Loading state (no src yet)
  if (!src) {
    return (
      <div className="flex items-center justify-center h-64 bg-muted/30">
        <ImageIcon className="h-8 w-8 text-muted-foreground animate-pulse" />
      </div>
    );
  }

  const viewer = (
    <TransformWrapper
      initialScale={1}
      minScale={MIN_ZOOM}
      maxScale={MAX_ZOOM}
      centerOnInit
      limitToBounds={false}
      onTransformed={(_, state) => {/* update scale */}}
    >
      <ImageViewerContent
        file={file}
        src={src}
        lqip={lqip}
        isLoading={isLoading}
        isReady={isReady}
        contentKey={contentKey}
        isLarge={isLarge}
        transform={transform}
        transformStyle={transformStyle}
        rotate={rotate}
        flipH={flipH}
        flipV={flipV}
        onExpand={() => setDialogOpen(true)}
        inDialog={inDialog}
      />
    </TransformWrapper>
  );

  return (
    <>
      <div className="relative w-full h-[400px] overflow-hidden bg-checkerboard rounded-lg">
        {viewer}
      </div>

      {/* Fullscreen dialog */}
      <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
        <DialogContent className="max-w-[95vw] max-h-[95vh] p-0">
          <ImageViewer file={file} content={content} inDialog />
        </DialogContent>
      </Dialog>
    </>
  );
}
```

### components/index.ts

```typescript
/**
 * ImageViewer components - Public API
 */

export { ImageViewer } from './ImageViewer';
export { ImageToolbar } from './ImageToolbar';
export { ImageInfo } from './ImageInfo';
```

## Notes

- ImageToolbar uses `useControls` from react-zoom-pan-pinch
- ImageInfo caches dimensions for performance
- Main component orchestrates everything
- Fullscreen dialog renders nested ImageViewer with `inDialog` flag
