planmymeal/components/foundations/template-copy/template-copy.tsx
Cayden Schwechten df5dd3a70e Move UI to BoardUI with slate + salmon theme; add PDF plan export
- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button,
  SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's
  color aliases onto BoardUI tokens and swap lucide for Remix icons
- Retint BoardUI's neutral ramp to cool slate with a salmon accent;
  drop the dotted background; raise muted text contrast
- Simplify the landing page and remove the overlap explorer
- Planner: explicit "I have this" toggle on grocery lines, pantry panel
  above the menu with the staples list spelled out, sticky aisle
  header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded)
- Recipes: replace the family cabbage soup with Cade's Cabbage Soup
  (adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup
- Remove unused BoardUI components, dependencies and exports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:36:33 -06:00

72 lines
4.3 KiB
TypeScript

"use client";
import { cloneElement, createContext, isValidElement, useContext, useMemo, type ReactNode } from "react";
export type TemplateCopyTranslator = (text: string) => string;
type CopySettings = { translate: TemplateCopyTranslator; render?: <T>(node: T) => T };
const CopyContext = createContext<CopySettings | null>(null);
const MenuContext = createContext<ReactNode>(null);
/** Optional application language control shared by template account menus. */
export function TemplateMenuProvider({ languageSelector, children }: { languageSelector?: ReactNode; children: ReactNode }) {
return <MenuContext.Provider value={languageSelector ?? null}>{children}</MenuContext.Provider>;
}
export function useTemplateLanguageSelector() {
return useContext(MenuContext);
}
/** Optional application copy for templates. Without a provider, source copy is unchanged. */
export function TemplateCopyProvider({ translate, render, children }: CopySettings & { children: ReactNode }) {
const settings = useMemo(() => ({ translate, render }), [translate, render]);
return <CopyContext.Provider value={settings}>{children}</CopyContext.Provider>;
}
// Only display copy is translated. IDs, form values, data keys, URLs, styles,
// event handlers and application state retain their original values.
const textProps = new Set([
"children", "title", "subtitle", "description", "label", "textValue", "placeholder", "alt",
"aria-label", "aria-description", "metricLabel", "emptyMessage", "caption", "heading", "text", "content", "renderValue",
]);
const dataProps = new Set(["items", "options", "tabs", "columns", "series", "ranges", "stats", "data", "nodes", "bar", "line"]);
const dataText = new Set(["label", "title", "description", "subtitle", "header", "role", "joined"]);
const renderProps = new Set(["children", "cell", "header", "formatter", "tickFormatter", "labelFormatter", "renderItem", "node", "link", "tick", "label", "content", "renderValue"]);
/** Translate rendered copy while preserving React nodes, collection IDs and callbacks. */
export function translateTemplateCopy<T>(value: T, translate: TemplateCopyTranslator): T {
function visit(value: unknown, data = false): unknown {
if (typeof value === "string") return data ? value : translate(value);
if (Array.isArray(value)) {
const result = value.map(item => visit(item, data));
return result.every((item, index) => item === value[index]) ? value : result;
}
if (isValidElement<Record<string, unknown>>(value)) {
if (typeof value.type === "string" && ["pre", "code", "kbd", "script", "style"].includes(value.type)) return value;
const updates: Record<string, unknown> = {};
for (const [key, original] of Object.entries(value.props)) {
if (textProps.has(key)) updates[key] = renderProps.has(key) && typeof original === "function"
? (...args: unknown[]) => visit(original(...args)) : visit(original);
else if (dataProps.has(key)) updates[key] = visit(original, true);
else if (renderProps.has(key) && typeof original === "function") updates[key] = (...args: unknown[]) => visit(original(...args));
}
return Object.entries(updates).some(([key, result]) => result !== value.props[key]) ? cloneElement(value, updates) : value;
}
if (data && value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
const result: Record<string, unknown> = { ...value };
for (const [key, original] of Object.entries(result)) {
if (renderProps.has(key) && typeof original === "function") result[key] = (...args: unknown[]) => visit(original(...args));
else if (dataText.has(key)) result[key] = visit(original);
else if (original && typeof original === "object") result[key] = visit(original, true);
}
return Object.entries(result).some(([key, item]) => item !== (value as Record<string, unknown>)[key]) ? result : value;
}
return value;
}
return visit(value) as T;
}
/** Apply to a template component's returned JSX; copy updates through React, including portals. */
export function useTemplateCopy() {
const settings = useContext(CopyContext);
return useMemo(() => <T,>(node: T): T => settings?.render ? settings.render(node) : settings ? translateTemplateCopy(node, settings.translate) : node, [settings]);
}