# Build the page from parts

`WrappedView` is a layout and about a hundred lines of wiring around five components that are all exported. When you want a different page (the settings in a drawer, the scan on its own route, no gallery) take the parts and do the wiring yourself. This guide goes through that wiring with a page that does everything `WrappedView` does and imports nothing from the kit.

- [From parts example](https://bettertyped.github.io/react-wrapped/examples/from-parts/): The page this guide builds, running. No WrappedView, no kit.
- [Full Wrapped example](https://bettertyped.github.io/react-wrapped/examples/full-wrapped/): The same year from the one component, to compare.

## The parts

| Part | Package | Its job on the page |
| --- | --- | --- |
| [`WrappedPlayer`](https://bettertyped.github.io/react-wrapped/docs/player/wrapped-player/) | `@react-wrapped/player` | Plays the scenes that are on. Its timeline edits the cut. |
| [`WrappedConfigurator`](https://bettertyped.github.io/react-wrapped/docs/player/configurator/) | `@react-wrapped/player` | Changes what applies to the whole video. |
| [`SceneEditor`](https://bettertyped.github.io/react-wrapped/docs/player/scene-editor/) | `@react-wrapped/player` | Changes one scene's texts and its transition. |
| [`YearScan`](https://bettertyped.github.io/react-wrapped/docs/scanner/year-scan/) | `@react-wrapped/scan` | Finds the hidden scenes. |
| [`GraphicsGallery`](https://bettertyped.github.io/react-wrapped/docs/graphics/gallery/) | `@react-wrapped/graphics` | Hands out the pictures. |
| `arrangeScenes`, `editsFor`, `luckyCut`, `resolvePalette` | `@react-wrapped/core` | The small functions between them. |

```bash
pnpm add @react-wrapped/core @react-wrapped/player @react-wrapped/scan @react-wrapped/graphics @react-wrapped/scenes gsap
```

```tsx
import "@react-wrapped/core/styles.css";
import "@react-wrapped/player/styles.css";
import "@react-wrapped/scan/styles.css";
import "@react-wrapped/graphics/styles.css";
```

## One config, one list of opened drops

The page owns two pieces of state. The config is everything a viewer can change. The list of opened drops is what the scan has found. Every part reads from those two and reports back to them, and nothing else is shared.

## From your scenes to the ones that play

Four steps turn the scenes your director returns into the array the player gets. Each is memoized on what it reads, so a change to one scene's text does not rebuild the others.

```tsx
// The drops opened so far. The director is asked again each time one is opened: that is how a find joins the video.
const { opened, open } = useOpenedDrops(null);
const cut = useMemo(() => directSample(sample.year, sample.traits, opened), [sample, opened]);
const scenes = useMemo(() => withDayScene(cut.scenes, sample.hours), [cut.scenes, sample.hours]);
// The viewer's order. A scene they never placed (a new find) goes after the scene the director put before it.
const arranged = useMemo(() => arrangeScenes(scenes, config.order), [scenes, config.order]);
// The viewer's rewritten texts, scene by scene. A scene nobody edited stays the same object.
const ordered = useMemo(
  () =>
    arranged.map((scene) => {
      const edits = editsFor(config.edits, scene.key);
      return Object.keys(edits).length > 0 ? scene.withEdits(edits) : scene;
    }),
  [arranged, config.edits],
);
// What plays. The timeline still gets every scene, so one that is off can be switched back on.
const playing = useMemo(
  () => ordered.filter((scene) => !config.hidden.includes(scene.key)),
  [ordered, config.hidden],
);
```

- `arrangeScenes` applies `config.order` and keeps a newly found scene in its story slot.
- `editsFor` picks one scene's edits out of the flat `config.edits`, and `scene.withEdits` applies them.
- The player gets `playing`. The timeline, through `editing.scenes`, gets `ordered`, so a hidden scene stays on the row.

## Editing from the timeline

`editing` is three callbacks. Two of them write to the config. The third opens the scene editor by remembering a key.

```tsx
const [editingKey, setEditingKey] = useState<string | null>(null);
const keys = ordered.map((scene) => scene.key);
const editing = {
  scenes: ordered,
  hidden: config.hidden,
  onToggle: (key: string, on: boolean) =>
    setConfig({
      ...config,
      hidden: on ? config.hidden.filter((hidden) => hidden !== key) : [...config.hidden, key],
    }),
  onMove: (key: string, by: -1 | 1) => {
    const from = keys.indexOf(key);
    const to = from + by;
    if (from === -1 || to < 0 || to >= keys.length) return;
    const order = [...keys];
    [order[from], order[to]] = [order[to], order[from]];
    setConfig({ ...config, order });
  },
  onEdit: setEditingKey,
};
```

## A transition per scene

The scene editor needs to know the way out of the scene it edits: the transition it plays now, the one the seed picked, and the scene that comes next. `luckyCut(seed, key)` is the seed's pick. Saving keeps a pick only when it differs from that, so the config stays short.

```tsx
// The way out of the scene being edited. The last scene playing has none, and neither has one switched off.
const editingAt = playing.findIndex((scene) => scene.key === editingKey);
const editingNext = editingAt === -1 ? undefined : playing[editingAt + 1];
const lucky = editingKey ? luckyCut(cut.seed, editingKey) : null;
const editingCut: SceneCut | null =
  editingKey && editingNext && lucky
    ? { style: config.cuts[editingKey] ?? lucky, original: lucky, next: editingNext }
    : null;
const saveScene = (values: Record<string, string>, style: WrappedTransition | null) => {
  if (!editingKey) return;
  const others = Object.entries(config.edits).filter(([name]) => !name.startsWith(`${editingKey}.`));
  const mine = Object.entries(values).map(([field, value]): [string, string] => [`${editingKey}.${field}`, value]);
  const cuts = Object.fromEntries(Object.entries(config.cuts).filter(([key]) => key !== editingKey));
  // Only a transition that differs from the seed's own pick is kept.
  if (style && style !== lucky) cuts[editingKey] = style;
  setConfig({ ...config, edits: Object.fromEntries([...others, ...mine]), cuts });
};
```

## The scan, first or under the video

`YearScan` adds scenes by calling `onOpen`, which here is the `open` of `useOpenedDrops`: the list changes, the director runs again, and the new scene is in `cut.scenes`. Give a drop's scene the key `drop-` followed by the drop's id: the found card flies to the timeline segment with that key. For the scan first flow, `useScanGate` says when the scan is the page: until one run has come to rest with something found, and never again after that. Render the scan alone while `gated` is true, and hand `onRunChange` to `YearScan`.

```tsx
const { gated, onRunChange } = useScanGate({ enabled: gate, found: opened.length, id: cut.seed });
// The video takes the scan's place at the top, so that is where the page should be looking.
const rootRef = useRef<HTMLDivElement>(null);
const wasGated = useRef(gated);
useLayoutEffect(() => {
  if (wasGated.current && !gated) rootRef.current?.scrollIntoView({ block: "start" });
  wasGated.current = gated;
}, [gated]);
```

## The graphics

The gallery takes a list of graphics and the palette. The list is memoized on the palette, because each tile draws its image again when its `image` function changes.

```tsx
const { palette: paletteId, accent, mode } = config;
const palette = useMemo(() => resolvePalette({ palette: paletteId, accent, mode }), [paletteId, accent, mode]);
const insights = useMemo(() => sampleInsights(sample.year, sample.traits), [sample]);
const graphics = useMemo((): WrappedGraphic[] => {
  const hidden = new Set(cut.pending.map((drop) => drop.id));
  return [
    {
      id: "share-card",
      title: "Share card",
      line: "The year on one card, sized for a post.",
      image: () => renderShareCard(cut.card, palette),
      fileName: `${sample.year.handle}-wrapped`,
      wide: true,
    },
    ...insights.map((card) => ({
      id: `insight-${card.id}`,
      title: card.name,
      line: card.insight,
      image: () => renderInsightCard(card, palette),
      fileName: `${sample.year.handle}-${card.id}`,
      wide: true,
      // A card that belongs to a drop stays locked until the scan has found that drop.
      locked: card.traitId ? hidden.has(card.traitId) : false,
    })),
  ];
}, [cut.card, cut.pending, insights, palette, sample.year.handle]);
```

## The layout is yours

The parts bring no page layout. The example lays them out with inline styles on the [chrome tokens](https://bettertyped.github.io/react-wrapped/docs/guides/palette-and-chrome/), so its panels follow the same light and dark as the parts inside them. Two things to know. Give the player's box a height when its edges should line up with a column beside it; with no height it takes its size from its width. And the scan, while it is the page, wants a box about as tall as the video will be, so the page does not jump when the video arrives.

## The whole file

The sample year comes from `@react-wrapped/sample`, a package inside the repository that is not published: it stands in for your own data and director. The example also adds one scene of its own with `chartScene`, to show a page mixing its scenes into a director's.

`from-parts.tsx`

```tsx
import { type CSSProperties, useLayoutEffect, useMemo, useRef, useState } from "react";

import { hourName, type SceneChart } from "@react-wrapped/charts";
import {
  arrangeScenes,
  DEFAULT_WRAPPED_CONFIG,
  editsFor,
  luckyCut,
  resolvePalette,
  type SceneInstance,
  type WrappedConfig,
  type WrappedTransition,
} from "@react-wrapped/core";
import { GraphicsGallery, renderInsightCard, renderShareCard, type WrappedGraphic } from "@react-wrapped/graphics";
import {
  type SceneCut,
  SceneEditor,
  ShareActions,
  WrappedConfigurator,
  WrappedPlayer,
  type WrappedPlayerHandle,
} from "@react-wrapped/player";
import { directSample, formatShort, hoursOfDay, sampleInsights, sampleTraits, sampleYear } from "@react-wrapped/sample";
import { useOpenedDrops, useScanGate, YearScan } from "@react-wrapped/scan";
import { chartScene } from "@react-wrapped/scenes";

/*
 * The same page as the full Wrapped example, put together by hand: no `WrappedView`, and nothing imported from
 * `@react-wrapped/kit`. Every piece comes from the package that owns it. The layout is the page's own, written as
 * inline styles on the library's chrome tokens, so it follows the same light and dark as the parts inside it.
 */

const PAGE: CSSProperties = { display: "grid", gap: 12, minWidth: 0 };
// The player and the column beside it: side by side where there is room, stacked on a phone.
const TOP: CSSProperties = { display: "flex", flexWrap: "wrap", gap: 12 };
const PANEL: CSSProperties = {
  minWidth: 0,
  padding: 20,
  borderRadius: 28,
  background: "var(--wr-ui-card)",
  color: "var(--wr-ui-fg)",
  boxShadow: "inset 0 0 0 1px var(--wr-ui-border)",
};
// The player fits its frame into the room it is given, so its panel has a height.
const PLAYER_PANEL: CSSProperties = { ...PANEL, flex: "999 1 420px", display: "grid", height: "min(780px, 92vh)" };
const SIDE: CSSProperties = { flex: "1 1 320px", display: "grid", gap: 12, alignContent: "start", minWidth: 0 };
// As the page, the scan takes the box the video is about to take.
const GATE_BOX: CSSProperties = { display: "grid", height: "min(780px, 92vh)", minHeight: 420 };
/** A scene this page adds to the director's own: the hours of the day on a clock, right after the week. */
function withDayScene(scenes: readonly SceneInstance[], hours: readonly number[]): SceneInstance[] {
  const chart: SceneChart = { type: "clock", hours };
  const day = chartScene(
    {
      eyebrow: "Your day",
      title: ["Your best hour is", `*${hourName(hours.indexOf(Math.max(...hours)))}.*`],
      chart,
      lede: "Commits by hour of the day. The lit hour is your busiest.",
    },
    { key: "day", label: "Your day" },
  );
  const after = scenes.findIndex((scene) => scene.key === "week");
  return [...scenes.slice(0, after + 1), day, ...scenes.slice(after + 1)];
}

export function FromParts({ gate = false }: { gate?: boolean }) {
  const [config, setConfig] = useState<WrappedConfig>({ ...DEFAULT_WRAPPED_CONFIG, format: "story" });
  const sample = useMemo(() => {
    const year = sampleYear();
    return { year, traits: sampleTraits(year), hours: hoursOfDay(year) };
  }, []);

  /* ---------- the scenes ---------- */

  // The drops opened so far. The director is asked again each time one is opened: that is how a find joins the video.
  const { opened, open } = useOpenedDrops(null);
  const cut = useMemo(() => directSample(sample.year, sample.traits, opened), [sample, opened]);
  const scenes = useMemo(() => withDayScene(cut.scenes, sample.hours), [cut.scenes, sample.hours]);
  // The viewer's order. A scene they never placed (a new find) goes after the scene the director put before it.
  const arranged = useMemo(() => arrangeScenes(scenes, config.order), [scenes, config.order]);
  // The viewer's rewritten texts, scene by scene. A scene nobody edited stays the same object.
  const ordered = useMemo(
    () =>
      arranged.map((scene) => {
        const edits = editsFor(config.edits, scene.key);
        return Object.keys(edits).length > 0 ? scene.withEdits(edits) : scene;
      }),
    [arranged, config.edits],
  );
  // What plays. The timeline still gets every scene, so one that is off can be switched back on.
  const playing = useMemo(
    () => ordered.filter((scene) => !config.hidden.includes(scene.key)),
    [ordered, config.hidden],
  );

  /* ---------- editing from the timeline ---------- */

  const [editingKey, setEditingKey] = useState<string | null>(null);
  const keys = ordered.map((scene) => scene.key);
  const editing = {
    scenes: ordered,
    hidden: config.hidden,
    onToggle: (key: string, on: boolean) =>
      setConfig({
        ...config,
        hidden: on ? config.hidden.filter((hidden) => hidden !== key) : [...config.hidden, key],
      }),
    onMove: (key: string, by: -1 | 1) => {
      const from = keys.indexOf(key);
      const to = from + by;
      if (from === -1 || to < 0 || to >= keys.length) return;
      const order = [...keys];
      [order[from], order[to]] = [order[to], order[from]];
      setConfig({ ...config, order });
    },
    onEdit: setEditingKey,
  };

  // The way out of the scene being edited. The last scene playing has none, and neither has one switched off.
  const editingAt = playing.findIndex((scene) => scene.key === editingKey);
  const editingNext = editingAt === -1 ? undefined : playing[editingAt + 1];
  const lucky = editingKey ? luckyCut(cut.seed, editingKey) : null;
  const editingCut: SceneCut | null =
    editingKey && editingNext && lucky
      ? { style: config.cuts[editingKey] ?? lucky, original: lucky, next: editingNext }
      : null;
  const saveScene = (values: Record<string, string>, style: WrappedTransition | null) => {
    if (!editingKey) return;
    const others = Object.entries(config.edits).filter(([name]) => !name.startsWith(`${editingKey}.`));
    const mine = Object.entries(values).map(([field, value]): [string, string] => [`${editingKey}.${field}`, value]);
    const cuts = Object.fromEntries(Object.entries(config.cuts).filter(([key]) => key !== editingKey));
    // Only a transition that differs from the seed's own pick is kept.
    if (style && style !== lucky) cuts[editingKey] = style;
    setConfig({ ...config, edits: Object.fromEntries([...others, ...mine]), cuts });
  };

  /* ---------- the scan first ---------- */

  // With `gate`, the scan is the page until one run has come to rest with something found.
  const { gated, onRunChange } = useScanGate({ enabled: gate, found: opened.length, id: cut.seed });
  // The video takes the scan's place at the top, so that is where the page should be looking.
  const rootRef = useRef<HTMLDivElement>(null);
  const wasGated = useRef(gated);
  useLayoutEffect(() => {
    if (wasGated.current && !gated) rootRef.current?.scrollIntoView({ block: "start" });
    wasGated.current = gated;
  }, [gated]);

  /* ---------- the graphics to keep ---------- */

  const { palette: paletteId, accent, mode } = config;
  const palette = useMemo(() => resolvePalette({ palette: paletteId, accent, mode }), [paletteId, accent, mode]);
  const insights = useMemo(() => sampleInsights(sample.year, sample.traits), [sample]);
  const graphics = useMemo((): WrappedGraphic[] => {
    const hidden = new Set(cut.pending.map((drop) => drop.id));
    return [
      {
        id: "share-card",
        title: "Share card",
        line: "The year on one card, sized for a post.",
        image: () => renderShareCard(cut.card, palette),
        fileName: `${sample.year.handle}-wrapped`,
        wide: true,
      },
      ...insights.map((card) => ({
        id: `insight-${card.id}`,
        title: card.name,
        line: card.insight,
        image: () => renderInsightCard(card, palette),
        fileName: `${sample.year.handle}-${card.id}`,
        wide: true,
        // A card that belongs to a drop stays locked until the scan has found that drop.
        locked: card.traitId ? hidden.has(card.traitId) : false,
      })),
    ];
  }, [cut.card, cut.pending, insights, palette, sample.year.handle]);

  // The player's handle: the share row borrows the stage from it to make the video file.
  const player = useRef<WrappedPlayerHandle>(null);
  // While the file is being made the stage is being filmed frame by frame: nothing else may move it.
  const [rendering, setRendering] = useState(false);

  return (
    <div ref={rootRef} style={PAGE}>
      {/* While the scan is the page there is no video. The scan below keeps its place in the tree either way. */}
      {gated ? null : (
        <div style={TOP}>
          <div style={PLAYER_PANEL}>
            <WrappedPlayer
              ref={player}
              subject={cut.subject}
              seed={cut.seed}
              scenes={playing}
              config={config}
              editing={editing}
            />
          </div>
          <div style={SIDE}>
            <div style={PANEL}>
              <ShareActions
                player={player}
                config={config}
                fileName={`${sample.year.handle}-wrapped.mp4`}
                onRendering={setRendering}
              />
            </div>
            <div style={PANEL} inert={rendering}>
              <WrappedConfigurator config={config} onChange={setConfig} />
            </div>
          </div>
        </div>
      )}
      <div style={gated ? GATE_BOX : undefined}>
        <YearScan
          calendar={cut.calendar}
          palette={palette}
          drops={cut.drops}
          found={opened}
          onOpen={open}
          describeDay={(day) => formatShort(sample.year.days[day].date)}
          gate={gated}
          onRunChange={onRunChange}
        />
      </div>
      {gated ? null : <GraphicsGallery graphics={graphics} palette={palette} />}
      <SceneEditor
        scene={ordered.find((scene) => scene.key === editingKey) ?? null}
        original={arranged.find((scene) => scene.key === editingKey) ?? null}
        cut={editingCut}
        config={config}
        onSave={saveScene}
        onClose={() => setEditingKey(null)}
      />
    </div>
  );
}
```

## What WrappedView adds on top

- The loading state in the same layout. Render [`WrappedPlayerLoading`](https://bettertyped.github.io/react-wrapped/docs/player/loading/) where the player will be.
- A viewer mode: with `editable` off there is no scan, no settings and no editing, only the video and what you pass as `viewer`.
- The settings column sliding open instead of appearing, and the video rising into the place the scan held.

## Related

- [From parts example](https://bettertyped.github.io/react-wrapped/examples/from-parts/)
- [Full Wrapped example](https://bettertyped.github.io/react-wrapped/examples/full-wrapped/)
- [Player](https://bettertyped.github.io/react-wrapped/docs/player/)
- [Scanner](https://bettertyped.github.io/react-wrapped/docs/scanner/)
- [Graphics gallery](https://bettertyped.github.io/react-wrapped/docs/graphics/gallery/)

Source: https://bettertyped.github.io/react-wrapped/docs/guides/from-parts/
