Skip to content
react-wrapped

The config in the URL

Everything a viewer can change is one WrappedConfig, and the whole of it fits in a query string. Put it there and the link is the video: whoever opens it sees the same palette, format, order, edits and transitions as the person who shared it. Three functions do the work, and none of them needs a router.

Nothing to add: every setting is at its default.

Change a setting and read the query. Or type a query and open it.

Three functions

FunctionWhat it does
parseWrappedSearch(input)Reads whatever a router or URLSearchParams hands over into a WrappedSearch. Anything unexpected becomes "not set". It never throws.
configFromSearch(search, defaults?)Turns a WrappedSearch into a full WrappedConfig, with your defaults for everything the link leaves out.
searchFromConfig(config, defaults?)The way back: a WrappedSearch with every key that is at its default left out.
link-is-the-video.tsx
import { type CSSProperties, useState } from "react";

import {
  configFromSearch,
  DEFAULT_WRAPPED_CONFIG,
  parseWrappedSearch,
  searchFromConfig,
  type WrappedConfig,
} from "@react-wrapped/core";
import { WrappedConfigurator } from "@react-wrapped/player";

/** The config as a query string. A setting at its default is left out, so most links are short. */
export function toQuery(config: WrappedConfig): string {
  const params = new URLSearchParams();
  for (const [key, value] of Object.entries(searchFromConfig(config))) {
    if (value !== undefined) params.set(key, String(value));
  }
  return params.toString();
}

/** A query string back into a config. Anything in it that makes no sense falls back to the default. */
export function fromQuery(query: string): WrappedConfig {
  return configFromSearch(parseWrappedSearch(Object.fromEntries(new URLSearchParams(query))));
}

const PANEL: CSSProperties = { maxWidth: 420 };

export function LinkIsTheVideo() {
  const [config, setConfig] = useState<WrappedConfig>(DEFAULT_WRAPPED_CONFIG);
  const [pasted, setPasted] = useState("p=grape&m=light&f=square&s=1.5");
  const query = toQuery(config);
  return (
    <div>
      <div style={PANEL}>
        <WrappedConfigurator config={config} onChange={setConfig} />
      </div>
      <output>{query ? `?${query}` : "Nothing to add: every setting is at its default."}</output>
      <div role="group" aria-label="Open a link">
        <input
          type="text"
          aria-label="A query string to open"
          value={pasted}
          onChange={(event) => setPasted(event.target.value)}
        />
        <button type="button" onClick={() => setConfig(fromQuery(pasted))}>
          Open this link
        </button>
      </div>
    </div>
  );
}

The short keys

A WrappedSearch uses one letter per setting, so a link stays readable when someone pastes it into a chat.

KeyTypeWhat it carries
p?stringpalette: the palette's id.
a?stringaccent: six hex digits without the hash. Only with the custom palette.
m?"dark" | "light"mode.
f?"story" | "square" | "landscape"format.
c?"round" | "square" | "circle"shape, the calendar's cells.
s?"0.75" | "1" | "1.5"speed.
t?stringcuts: picked transitions as scene:transition pairs with commas between them.
h?stringhidden: scene keys with commas between them.
o?stringorder: scene keys with commas between them.
e?stringedits: the rewritten texts as one JSON object.
g?0grain: there only when the grain is off.
bash
?p=grape&m=light&f=square&t=total:drip,week:iris&h=languages

What is left out

  • A key at its default is not written. A video nobody changed has no query at all.
  • a is written only with the custom palette. g is written only as 0, when the grain is off.
  • The scene editor stores only texts that differ from the original and only a transition that differs from the seed's pick, so e and t stay short too.
  • Pass the same defaults to configFromSearch and searchFromConfig. Then "left out" means the same thing both ways.
  • Every value is checked: the format against the three formats, the accent against six hex digits, each text against a length limit.
  • A transition the page does not know is dropped and that scene plays the seed's pick. Scene keys that no longer exist are ignored.
  • Edits that are not a flat object of short strings are dropped as a whole.
  • Palette ids that were renamed still open as what they were.

In the address bar

With no router, a small hook reads the link once and writes it back on every change. WrappedView and every other part take the pair it returns as config and onConfigChange.

use-url-config.ts
import { useCallback, useEffect, useState } from "react";

import {
  configFromSearch,
  DEFAULT_WRAPPED_CONFIG,
  parseWrappedSearch,
  searchFromConfig,
  type WrappedConfig,
} from "@react-wrapped/core";

/** The config, kept in the address bar: read from the link when the page opens, written back on every change. */
export function useUrlConfig(defaults: WrappedConfig = DEFAULT_WRAPPED_CONFIG) {
  const [config, setConfig] = useState(defaults);

  // After the first paint: the server does not know the query string of a static page, so both render the defaults.
  useEffect(() => {
    const query = Object.fromEntries(new URLSearchParams(window.location.search));
    setConfig(configFromSearch(parseWrappedSearch(query), defaults));
  }, [defaults]);

  const change = useCallback(
    (next: WrappedConfig) => {
      setConfig(next);
      const params = new URLSearchParams();
      // The same defaults both ways, so "left out" means the same thing when the link is read again.
      for (const [key, value] of Object.entries(searchFromConfig(next, defaults))) {
        if (value !== undefined) params.set(key, String(value));
      }
      const query = params.toString();
      // Replaced, not pushed: trying six palettes should not take six presses of the back button to undo.
      window.history.replaceState(null, "", `${window.location.pathname}${query ? `?${query}` : ""}`);
    },
    [defaults],
  );

  return [config, change] as const;
}

With a router, parseWrappedSearch is shaped to be a route's search validator: it takes the raw search object and returns a typed one. Read the route's search, pass it through configFromSearch, and on change navigate with searchFromConfig(next) and replace.

Last updated . Read this page as Markdown.