import { useId, type ReactNode } from 'react'; import { ImageIcon, XIcon } from 'lucide-react'; import { OptionTip } from '../../components/seo/option-row'; import { VariablesMenu, type VariableOption, } from '../../components/seo/variables-menu'; import { Button } from '../../components/ui/button'; import { Checkbox } from '../../components/ui/checkbox'; import { Input } from '../../components/ui/input'; import { Label } from '../../components/ui/label'; import { Switch } from '../../components/ui/switch'; import { Textarea } from '../../components/ui/textarea'; import { classNames } from '../../lib/utils'; import { useAttachmentSize, useAttachmentUrl } from '../hooks/use-seo-meta'; import { textWidth } from '../lib/text-width'; /** A label with its tooltip, compact for the 280px sidebar. */ export const FieldHeader = ({ label, tip, htmlFor, children, }: { label: string; tip?: string; htmlFor?: string; children?: ReactNode; }): JSX.Element => (
{tip && {tip}} {children && {children}}
); /** * The bar under a title or description: how long it is against the range * Google shows, green inside the range, amber when short, red when cut. */ export const LengthBar = ({ length, min, max, pixels, maxPixels, }: { length: number; min: number; max: number; /** The rendered width, when measured, judged against maxPixels. */ pixels?: number; maxPixels?: number; }): JSX.Element => { const measured = pixels !== undefined && maxPixels !== undefined && pixels > 0; const percent = Math.min( 100, Math.round( measured ? (pixels / (maxPixels as number)) * 100 : (length / max) * 100 ) ); const cut = measured ? pixels > (maxPixels as number) : length > max; const tone = length === 0 ? 'bg-muted-foreground/30' : cut ? 'bg-red-500' : length < min ? 'bg-amber-500' : 'bg-emerald-500'; return (
{length} / {max} {measured && ( {' '} · {pixels} / {maxPixels} px )}
); }; /** A template field: text plus the variables menu, the rendered preview and the length bar. */ export const TemplateField = ({ label, tip, value, onChange, placeholder, variables, rendered, min, max, multiline, font, maxPixels, }: { label: string; tip?: string; value: string; onChange: (value: string) => void; placeholder: string; variables: VariableOption[]; rendered: string; min: number; max: number; multiline?: boolean; /** Measure the rendered text in this font against maxPixels, like Google does. */ font?: string; maxPixels?: number; }): JSX.Element => { const id = useId(); const pixels = font ? textWidth(rendered, font) : undefined; const insert = (token: string): void => { const element = document.getElementById(id) as | HTMLInputElement | HTMLTextAreaElement | null; // An empty field prints its template: the variable goes into that, at // its end, not in its place (a title that became just %focuskw%). const text = value === '' ? placeholder : value; const start = value === '' ? text.length : (element?.selectionStart ?? text.length); const end = value === '' ? start : (element?.selectionEnd ?? start); const before = text.slice(0, start); const after = text.slice(end); onChange( `${before && !before.endsWith(' ') ? `${before} ` : before}${token}${ after && !after.startsWith(' ') ? ` ${after}` : after }` ); requestAnimationFrame(() => element?.focus()); }; return (
{multiline ? (