chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -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";
|
||||
Reference in New Issue
Block a user