e30e75b5d4
Code Quality / Oxlint + Oxfmt (push) Waiting to run
Code Quality / Template Sync (push) Waiting to run
Code Quality / Build Changed Packages (push) Waiting to run
Code Quality / Test Changed Packages (push) Waiting to run
Deploy Expo Example / Deploy Production (push) Waiting to run
Deploy Ink Example / Deploy Production (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.12) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Waiting to run
Deploy Shadcn Registry / Deploy Production (push) Waiting to run
Template Metrics / LOC + Bundle Size (push) Waiting to run
Changesets / Create Version PR (push) Has been cancelled
315 lines
8.2 KiB
TypeScript
315 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createParser,
|
|
parseAsBoolean,
|
|
parseAsStringLiteral,
|
|
useQueryStates,
|
|
type inferParserType,
|
|
} from "nuqs";
|
|
import { useCallback, useMemo } from "react";
|
|
import { type BuilderConfig, DEFAULT_CONFIG } from "@/components/builder/types";
|
|
import {
|
|
PRESETS,
|
|
getPresetById,
|
|
configMatchesPreset,
|
|
} from "@/components/builder/presets";
|
|
|
|
// Preset IDs from presets.ts
|
|
const PRESET_IDS = PRESETS.map((p) => p.id);
|
|
|
|
/**
|
|
* Compute the difference between config and defaults
|
|
* Only stores values that differ from DEFAULT_CONFIG
|
|
*/
|
|
export function computeDiff(
|
|
config: BuilderConfig,
|
|
defaults: BuilderConfig,
|
|
): Record<string, unknown> {
|
|
function deepDiff(current: unknown, defaultVal: unknown): unknown {
|
|
if (current === defaultVal) return undefined;
|
|
if (
|
|
typeof current !== "object" ||
|
|
current === null ||
|
|
typeof defaultVal !== "object" ||
|
|
defaultVal === null
|
|
) {
|
|
return current;
|
|
}
|
|
|
|
const result: Record<string, unknown> = {};
|
|
let hasChanges = false;
|
|
|
|
for (const key of Object.keys(current as Record<string, unknown>)) {
|
|
const currentObj = current as Record<string, unknown>;
|
|
const defaultObj = defaultVal as Record<string, unknown>;
|
|
const childDiff = deepDiff(currentObj[key], defaultObj[key]);
|
|
if (childDiff !== undefined) {
|
|
result[key] = childDiff;
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
|
|
return hasChanges ? result : undefined;
|
|
}
|
|
|
|
return (deepDiff(config, defaults) as Record<string, unknown>) || {};
|
|
}
|
|
|
|
/**
|
|
* Apply diff to defaults to reconstruct full config
|
|
*/
|
|
export function applyDiff(
|
|
diff: Record<string, unknown>,
|
|
defaults: BuilderConfig,
|
|
): BuilderConfig {
|
|
function deepMerge(target: unknown, source: unknown): unknown {
|
|
if (source === undefined || source === null) return target;
|
|
if (typeof source !== "object" || typeof target !== "object") return source;
|
|
if (target === null) return source;
|
|
|
|
const result = { ...(target as Record<string, unknown>) };
|
|
for (const key of Object.keys(source as Record<string, unknown>)) {
|
|
const sourceObj = source as Record<string, unknown>;
|
|
result[key] = deepMerge(result[key], sourceObj[key]);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return deepMerge(defaults, diff) as BuilderConfig;
|
|
}
|
|
|
|
export function base64UrlEncode(str: string): string {
|
|
if (typeof window !== "undefined") {
|
|
const bytes = new TextEncoder().encode(str);
|
|
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
|
|
"",
|
|
);
|
|
return btoa(binary)
|
|
.replace(/\+/g, "-")
|
|
.replace(/\//g, "_")
|
|
.replace(/=+$/, "");
|
|
}
|
|
return Buffer.from(str, "utf-8").toString("base64url");
|
|
}
|
|
|
|
export function base64UrlDecode(str: string): string {
|
|
const pad = str.length % 4;
|
|
const padded = pad ? str + "=".repeat(4 - pad) : str;
|
|
const base64 = padded.replace(/-/g, "+").replace(/_/g, "/");
|
|
|
|
if (typeof window !== "undefined") {
|
|
const binary = atob(base64);
|
|
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
return new TextDecoder().decode(bytes);
|
|
}
|
|
return Buffer.from(base64, "base64").toString("utf-8");
|
|
}
|
|
|
|
/**
|
|
* Encode a BuilderConfig to a URL-safe string
|
|
*/
|
|
export function encodeConfig(config: BuilderConfig): string {
|
|
const diff = computeDiff(config, DEFAULT_CONFIG);
|
|
const json = JSON.stringify(diff);
|
|
return base64UrlEncode(json);
|
|
}
|
|
|
|
/**
|
|
* Decode a URL-safe string back to a BuilderConfig
|
|
*/
|
|
export function decodeConfig(encoded: string): BuilderConfig {
|
|
const json = base64UrlDecode(encoded);
|
|
const diff = JSON.parse(json) as Record<string, unknown>;
|
|
return applyDiff(diff, DEFAULT_CONFIG);
|
|
}
|
|
|
|
/**
|
|
* Parser for BuilderConfig using incremental encoding + Base64
|
|
*/
|
|
export const parseAsConfig = createParser<BuilderConfig>({
|
|
parse(query: string): BuilderConfig | null {
|
|
try {
|
|
const json = base64UrlDecode(query);
|
|
const diff = JSON.parse(json) as Record<string, unknown>;
|
|
return applyDiff(diff, DEFAULT_CONFIG);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
serialize(config: BuilderConfig): string {
|
|
const diff = computeDiff(config, DEFAULT_CONFIG);
|
|
const json = JSON.stringify(diff);
|
|
return base64UrlEncode(json);
|
|
},
|
|
eq(a: BuilderConfig, b: BuilderConfig): boolean {
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Parser for viewport width (number or "100%")
|
|
*/
|
|
export const parseAsViewportWidth = createParser<number | "100%">({
|
|
parse(query: string): number | "100%" | null {
|
|
if (query === "full") return "100%";
|
|
const num = parseInt(query, 10);
|
|
return Number.isNaN(num) ? null : num;
|
|
},
|
|
serialize(value: number | "100%"): string {
|
|
return value === "100%" ? "full" : String(value);
|
|
},
|
|
eq(a, b) {
|
|
return a === b;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Parser for preset ID
|
|
*/
|
|
export const parseAsPreset = createParser<string>({
|
|
parse(query: string): string | null {
|
|
return PRESET_IDS.includes(query) ? query : null;
|
|
},
|
|
serialize(value: string): string {
|
|
return value;
|
|
},
|
|
eq(a, b) {
|
|
return a === b;
|
|
},
|
|
});
|
|
|
|
export const playgroundSearchParams = {
|
|
preset: parseAsPreset, // Preset shortcut (e.g., ?preset=chatgpt)
|
|
c: parseAsConfig.withDefault(DEFAULT_CONFIG), // Config (incremental encoded)
|
|
code: parseAsBoolean.withDefault(false), // Show code panel
|
|
vp: parseAsStringLiteral([
|
|
"desktop",
|
|
"tablet",
|
|
"mobile",
|
|
] as const).withDefault("desktop"), // Viewport preset
|
|
vw: parseAsViewportWidth.withDefault("100%"), // Viewport width
|
|
};
|
|
|
|
export type PlaygroundSearchParams = inferParserType<
|
|
typeof playgroundSearchParams
|
|
>;
|
|
|
|
export type ViewportPreset = "desktop" | "tablet" | "mobile";
|
|
|
|
export interface UsePlaygroundStateOptions {
|
|
throttleMs?: number;
|
|
}
|
|
|
|
export interface PlaygroundState {
|
|
config: BuilderConfig;
|
|
showCode: boolean;
|
|
viewportPreset: ViewportPreset | null;
|
|
viewportWidth: number | "100%";
|
|
setConfig: (config: BuilderConfig) => void;
|
|
setShowCode: (showCode: boolean) => void;
|
|
setViewportPreset: (preset: ViewportPreset) => void;
|
|
setViewportWidth: (width: number) => void;
|
|
}
|
|
|
|
const VIEWPORT_WIDTHS: Record<ViewportPreset, number | "100%"> = {
|
|
desktop: "100%",
|
|
tablet: 768,
|
|
mobile: 375,
|
|
};
|
|
|
|
export function usePlaygroundState(
|
|
options: UsePlaygroundStateOptions = {},
|
|
): PlaygroundState {
|
|
const { throttleMs = 300 } = options;
|
|
|
|
const [state, setState] = useQueryStates(playgroundSearchParams, {
|
|
history: "replace",
|
|
shallow: true,
|
|
throttleMs,
|
|
});
|
|
|
|
// Compute effective config: preset takes priority over c
|
|
const config = useMemo(() => {
|
|
if (state.preset) {
|
|
const preset = getPresetById(state.preset);
|
|
return preset?.config ?? DEFAULT_CONFIG;
|
|
}
|
|
return state.c;
|
|
}, [state.preset, state.c]);
|
|
|
|
// Compute effective viewport preset
|
|
const viewportPreset = useMemo((): ViewportPreset | null => {
|
|
const vp = state.vp;
|
|
if (vp === "desktop" || vp === "tablet" || vp === "mobile") {
|
|
// Verify width matches preset
|
|
if (state.vw === VIEWPORT_WIDTHS[vp]) {
|
|
return vp;
|
|
}
|
|
}
|
|
return null;
|
|
}, [state.vp, state.vw]);
|
|
|
|
// Set config - auto-detect if it matches a preset
|
|
const setConfig = useCallback(
|
|
(newConfig: BuilderConfig) => {
|
|
const matchingPreset = configMatchesPreset(newConfig);
|
|
if (matchingPreset) {
|
|
// Use preset shortcut if config matches exactly
|
|
setState({
|
|
preset: matchingPreset.id,
|
|
c: null,
|
|
});
|
|
} else {
|
|
// Store as incremental diff
|
|
setState({
|
|
preset: null,
|
|
c: newConfig,
|
|
});
|
|
}
|
|
},
|
|
[setState],
|
|
);
|
|
|
|
// Set show code
|
|
const setShowCode = useCallback(
|
|
(showCode: boolean) => {
|
|
setState({ code: showCode });
|
|
},
|
|
[setState],
|
|
);
|
|
|
|
// Set viewport preset
|
|
const setViewportPreset = useCallback(
|
|
(preset: ViewportPreset) => {
|
|
setState({
|
|
vp: preset,
|
|
vw: VIEWPORT_WIDTHS[preset],
|
|
});
|
|
},
|
|
[setState],
|
|
);
|
|
|
|
// Set custom viewport width (clears preset)
|
|
const setViewportWidth = useCallback(
|
|
(width: number) => {
|
|
setState({
|
|
vp: null,
|
|
vw: width,
|
|
});
|
|
},
|
|
[setState],
|
|
);
|
|
|
|
return {
|
|
config,
|
|
showCode: state.code,
|
|
viewportPreset,
|
|
viewportWidth: state.vw,
|
|
setConfig,
|
|
setShowCode,
|
|
setViewportPreset,
|
|
setViewportWidth,
|
|
};
|
|
}
|