chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,316 @@
"use client";
import { clsx } from "clsx";
import { DateInput } from "~/components/ui/DateInput.js";
import { FieldType } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
interface Props {
fieldDefs: BlueprintFieldDefinition[];
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
errors?: Record<string, string>;
className?: string;
}
const INPUT_BASE =
"w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors";
const INPUT_NORMAL = "border-gray-300 bg-white text-gray-900";
const INPUT_ERROR = "border-red-400 bg-red-50 text-gray-900";
function inputClass(hasError: boolean) {
return clsx(INPUT_BASE, hasError ? INPUT_ERROR : INPUT_NORMAL);
}
interface FieldInputProps {
fieldDef: BlueprintFieldDefinition;
value: unknown;
onChange: (key: string, value: unknown) => void;
hasError: boolean;
}
function FieldInput({ fieldDef, value, onChange, hasError }: FieldInputProps) {
const { key, type, placeholder, validation, options } = fieldDef;
switch (type) {
case FieldType.TEXT:
return (
<input
type="text"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder}
maxLength={validation?.maxLength}
minLength={validation?.minLength}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
/>
);
case FieldType.TEXTAREA:
return (
<textarea
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder}
maxLength={validation?.maxLength}
onChange={(e) => onChange(key, e.target.value)}
className={clsx(inputClass(hasError), "resize-y min-h-[80px]")}
rows={3}
/>
);
case FieldType.NUMBER:
return (
<input
type="number"
id={key}
value={value !== undefined && value !== null && value !== "" ? Number(value) : ""}
placeholder={placeholder}
min={validation?.min}
max={validation?.max}
onChange={(e) =>
onChange(key, e.target.value === "" ? "" : Number(e.target.value))
}
className={inputClass(hasError)}
/>
);
case FieldType.BOOLEAN: {
const checked = value === true || value === "true" || value === 1;
return (
<label className="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
id={key}
checked={checked}
onChange={(e) => onChange(key, e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">
{checked ? "Yes" : "No"}
</span>
</label>
);
}
case FieldType.DATE:
return (
<DateInput
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
onChange={(v) => onChange(key, v)}
className={inputClass(hasError)}
/>
);
case FieldType.SELECT:
return (
<select
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
>
<option value="">{placeholder ?? "Select…"}</option>
{(options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
case FieldType.MULTI_SELECT: {
const selectedVals = Array.isArray(value) ? value.map(String) : [];
return (
<div className="space-y-1.5">
{(options ?? []).map((opt) => {
const checked = selectedVals.includes(opt.value);
return (
<label key={opt.value} className="inline-flex items-center gap-2 cursor-pointer mr-4">
<input
type="checkbox"
value={opt.value}
checked={checked}
onChange={(e) => {
const next = e.target.checked
? [...selectedVals, opt.value]
: selectedVals.filter((v) => v !== opt.value);
onChange(key, next);
}}
className="w-4 h-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
/>
<span className="text-sm text-gray-700">{opt.label}</span>
</label>
);
})}
</div>
);
}
case FieldType.URL:
return (
<input
type="url"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder ?? "https://"}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
/>
);
case FieldType.EMAIL:
return (
<input
type="email"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder ?? "email@example.com"}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
/>
);
default:
return (
<input
type="text"
id={key}
value={typeof value === "string" ? value : (value ?? "") as string}
placeholder={placeholder}
onChange={(e) => onChange(key, e.target.value)}
className={inputClass(hasError)}
/>
);
}
}
interface FieldWrapperProps {
fieldDef: BlueprintFieldDefinition;
value: unknown;
onChange: (key: string, value: unknown) => void;
error?: string;
}
function FieldWrapper({ fieldDef, value, onChange, error }: FieldWrapperProps) {
const hasError = Boolean(error);
return (
<div className="flex flex-col gap-1">
<label
htmlFor={fieldDef.key}
className="text-sm font-medium text-gray-700"
>
{fieldDef.label}
{fieldDef.required && (
<span className="ml-0.5 text-red-500" aria-hidden="true">
*
</span>
)}
</label>
<FieldInput
fieldDef={fieldDef}
value={value}
onChange={onChange}
hasError={hasError}
/>
{fieldDef.description && !error && (
<p className="text-xs text-gray-400">{fieldDef.description}</p>
)}
{error && (
<p className="text-xs text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}
function FieldGroup({
group,
fields,
values,
onChange,
errors,
}: {
group: string;
fields: BlueprintFieldDefinition[];
values: Record<string, unknown>;
onChange: (key: string, value: unknown) => void;
errors?: Record<string, string>;
}) {
return (
<fieldset className="space-y-4 border border-gray-200 rounded-lg p-4">
<legend className="text-sm font-semibold text-gray-700 px-1">{group}</legend>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{fields.map((field) => (
<FieldWrapper
key={field.id}
fieldDef={field}
value={values[field.key]}
onChange={onChange}
{...(errors?.[field.key] !== undefined ? { error: errors[field.key] } : {})}
/>
))}
</div>
</fieldset>
);
}
export function DynamicFieldEditor({
fieldDefs,
values,
onChange,
errors,
className,
}: Props) {
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
const ungrouped = sorted.filter((f) => !f.group);
const groupMap = new Map<string, BlueprintFieldDefinition[]>();
for (const field of sorted) {
if (!field.group) continue;
const existing = groupMap.get(field.group) ?? [];
existing.push(field);
groupMap.set(field.group, existing);
}
if (sorted.length === 0) {
return null;
}
return (
<div className={clsx("space-y-6", className)}>
{ungrouped.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{ungrouped.map((field) => (
<FieldWrapper
key={field.id}
fieldDef={field}
value={values[field.key]}
onChange={onChange}
{...(errors?.[field.key] !== undefined ? { error: errors[field.key] } : {})}
/>
))}
</div>
)}
{[...groupMap.entries()].map(([group, fields]) => (
<FieldGroup
key={group}
group={group}
fields={fields}
values={values}
onChange={onChange}
{...(errors !== undefined ? { errors } : {})}
/>
))}
</div>
);
}
@@ -0,0 +1,152 @@
"use client";
import { clsx } from "clsx";
import { formatDateLong } from "~/lib/format.js";
import { FieldType } from "@planarchy/shared";
import type { BlueprintFieldDefinition } from "@planarchy/shared";
interface Props {
fieldDefs: BlueprintFieldDefinition[];
values: Record<string, unknown>;
className?: string;
}
function renderValue(fieldDef: BlueprintFieldDefinition, value: unknown): React.ReactNode {
if (value === null || value === undefined || value === "") {
return <span className="text-gray-400"></span>;
}
switch (fieldDef.type) {
case FieldType.TEXT:
case FieldType.TEXTAREA:
case FieldType.URL:
case FieldType.EMAIL:
return <span className="text-gray-900">{String(value)}</span>;
case FieldType.NUMBER:
return (
<span className="text-gray-900">
{typeof value === "number" ? value.toLocaleString() : String(value)}
</span>
);
case FieldType.BOOLEAN: {
const bool = value === true || value === "true" || value === 1;
return (
<span
className={clsx(
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
bool
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-500",
)}
>
{bool ? "Yes" : "No"}
</span>
);
}
case FieldType.DATE: {
const dateStr = String(value);
const parsed = new Date(dateStr);
if (isNaN(parsed.getTime())) {
return <span className="text-gray-900">{dateStr}</span>;
}
return <span className="text-gray-900">{formatDateLong(parsed)}</span>;
}
case FieldType.SELECT: {
const strVal = String(value);
const option = fieldDef.options?.find((o) => o.value === strVal);
return <span className="text-gray-900">{option?.label ?? strVal}</span>;
}
case FieldType.MULTI_SELECT: {
const rawVals = Array.isArray(value) ? value : [value];
const strVals = rawVals.map((v) => String(v)).filter(Boolean);
if (strVals.length === 0) {
return <span className="text-gray-400"></span>;
}
const labels = strVals.map((v) => {
const option = fieldDef.options?.find((o) => o.value === v);
return { value: v, label: option?.label ?? v, color: option?.color };
});
return (
<div className="flex flex-wrap gap-1">
{labels.map(({ value: v, label }) => (
<span
key={v}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-brand-50 text-brand-700"
>
{label}
</span>
))}
</div>
);
}
default:
return <span className="text-gray-900">{String(value)}</span>;
}
}
function FieldRow({ fieldDef, value }: { fieldDef: BlueprintFieldDefinition; value: unknown }) {
return (
<div className="flex flex-col gap-0.5">
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wider">
{fieldDef.label}
</dt>
<dd className="text-sm">{renderValue(fieldDef, value)}</dd>
{fieldDef.description && (
<p className="text-xs text-gray-400">{fieldDef.description}</p>
)}
</div>
);
}
export function DynamicFieldRenderer({ fieldDefs, values, className }: Props) {
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
// Separate grouped and ungrouped fields
const ungrouped = sorted.filter((f) => !f.group);
const groupMap = new Map<string, BlueprintFieldDefinition[]>();
for (const field of sorted) {
if (!field.group) continue;
const existing = groupMap.get(field.group) ?? [];
existing.push(field);
groupMap.set(field.group, existing);
}
if (sorted.length === 0) {
return null;
}
return (
<div className={clsx("space-y-6", className)}>
{ungrouped.length > 0 && (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{ungrouped.map((field) => (
<FieldRow key={field.id} fieldDef={field} value={values[field.key]} />
))}
</dl>
)}
{[...groupMap.entries()].map(([group, fields]) => (
<div key={group} className="space-y-3">
<h4 className="text-sm font-semibold text-gray-700 border-b border-gray-200 pb-1">
{group}
</h4>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{fields.map((field) => (
<FieldRow key={field.id} fieldDef={field} value={values[field.key]} />
))}
</dl>
</div>
))}
</div>
);
}
@@ -0,0 +1,2 @@
export { DynamicFieldRenderer } from "./DynamicFieldRenderer.js";
export { DynamicFieldEditor } from "./DynamicFieldEditor.js";