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.
Change a setting and read the query. Or type a query and open it.
Three functions
| Function | What 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. |
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.
| Key | Type | What it carries |
|---|---|---|
p? | string | palette: the palette's id. |
a? | string | accent: 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? | string | cuts: picked transitions as scene:transition pairs with commas between them. |
h? | string | hidden: scene keys with commas between them. |
o? | string | order: scene keys with commas between them. |
e? | string | edits: the rewritten texts as one JSON object. |
g? | 0 | grain: there only when the grain is off. |
?p=grape&m=light&f=square&t=total:drip,week:iris&h=languagesWhat is left out
- A key at its default is not written. A video nobody changed has no query at all.
ais written only with the custom palette.gis written only as0, 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
eandtstay short too. - Pass the same
defaultstoconfigFromSearchandsearchFromConfig. Then "left out" means the same thing both ways.
A mangled link still plays
- 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.
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.
