From b2a9518aa30d1e5eb3388dd03b1fc1b8169f0864 Mon Sep 17 00:00:00 2001 From: rafavalls Date: Thu, 7 May 2026 10:42:37 -0300 Subject: [PATCH 1/4] revamp form fields: bigger inputs, real pickers, 11 new components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing-field UX: - inputs to h-10 (40px); RichText toolbar buttons + CodeField header bigger - RichText toolbar no longer wraps — secondary buttons in a "More" menu - ObjectField is a proper input-styled row; nested children get an indent + rule - ImageField/MediaField fixed h-48 with cleaner empty state and hover actions - ColorInputField now react-colorful in a popover with 12 presets + eyedropper - DateField now shadcn Calendar+Popover; embedded TimeField in compact mode - TimeField is typeable HH:MM with hour/minute popover columns - Array rows + section rows stay highlighted while their dropdown menu is open - Select dropdown items use text-sm with breathing room New components (cms-form-extras.tsx) wired by schemaFormat: - SwitchField (boolean, format=switch) - TagsField (array, format=tags) - MultiSelectField (array, format=multi-select with items.enum) - SlugField (string, format=slug) - UrlField (string, format=url) with favicon + validity badge - MarkdownField (string, format=markdown) with write/preview tabs - ReferenceField (string, format=reference) with searchable picker - IconField (string, format=icon) Lucide picker - TimeField (string, format=time) - DateRangeField (object, format=date-range) - RangeField (number, format=range) slider deps: react-colorful, marked --- package.json | 2 + web/tools/file-explorer/cms-form-extras.tsx | 1142 +++++++++++++++++++ web/tools/file-explorer/cms-form.tsx | 974 ++++++++++------ 3 files changed, 1763 insertions(+), 355 deletions(-) create mode 100644 web/tools/file-explorer/cms-form-extras.tsx diff --git a/package.json b/package.json index 7f5bc6c..e409886 100644 --- a/package.json +++ b/package.json @@ -53,10 +53,12 @@ "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "lucide-react": "^0.576.0", + "marked": "^18.0.3", "monaco-editor": "^0.55.1", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19.2.0", + "react-colorful": "^5.6.1", "react-day-picker": "^9.14.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.2", diff --git a/web/tools/file-explorer/cms-form-extras.tsx b/web/tools/file-explorer/cms-form-extras.tsx new file mode 100644 index 0000000..522f3d7 --- /dev/null +++ b/web/tools/file-explorer/cms-form-extras.tsx @@ -0,0 +1,1142 @@ +import { format as formatDate } from "date-fns"; +import * as LucideIcons from "lucide-react"; +import { + Calendar as CalendarIcon, + Check, + ChevronDown, + Clock, + Eye, + FileText, + Link as LinkIcon, + Lock, + type LucideIcon, + Search, + Unlock, + X, +} from "lucide-react"; +import { marked } from "marked"; +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button.tsx"; +import { Calendar } from "@/components/ui/calendar.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover.tsx"; +import { Slider } from "@/components/ui/slider.tsx"; +import { Switch } from "@/components/ui/switch.tsx"; +import { Textarea } from "@/components/ui/textarea.tsx"; +import { cn } from "@/lib/utils.ts"; +import { FieldLabel } from "./cms-form.tsx"; + +// ─── 1. SwitchField — boolean toggle ───────────────────────────────────────── + +export function SwitchField({ + label, + description, + value, + onChange, +}: { + label: string; + description?: string; + value: boolean; + onChange: (v: boolean) => void; +}) { + return ( +
+
+ + {label} + + {description && ( + + {description} + + )} +
+ +
+ ); +} + +// ─── 2. TagsField — chip-based string array ────────────────────────────────── + +export function TagsField({ + label, + description, + value, + onChange, + placeholder = "Add tag…", +}: { + label: string; + description?: string; + value: string[]; + onChange: (v: string[]) => void; + placeholder?: string; +}) { + const [draft, setDraft] = useState(""); + + const commit = () => { + const trimmed = draft.trim(); + if (!trimmed) return; + if (value.includes(trimmed)) { + setDraft(""); + return; + } + onChange([...value, trimmed]); + setDraft(""); + }; + + return ( +
+ +
+ {value.map((tag) => ( + + {tag} + + + ))} + setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + commit(); + } + if (e.key === "Backspace" && !draft && value.length > 0) { + onChange(value.slice(0, -1)); + } + }} + onBlur={commit} + placeholder={value.length === 0 ? placeholder : ""} + className="min-w-[80px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+
+ ); +} + +// ─── 3. SlugField — auto-generated URL slug with lock ──────────────────────── + +function slugify(input: string): string { + return input + .toLowerCase() + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-"); +} + +export function SlugField({ + label, + description, + value, + onChange, + prefix = "/", +}: { + label: string; + description?: string; + value: string; + onChange: (v: string) => void; + prefix?: string; +}) { + const [locked, setLocked] = useState(true); + const [draft, setDraft] = useState(value); + + useEffect(() => { + setDraft(value); + }, [value]); + + return ( +
+ +
+ + {prefix} + + { + const v = slugify(e.target.value); + setDraft(v); + onChange(v); + }} + placeholder="my-awesome-page" + className="flex-1 bg-transparent px-3 font-mono text-sm outline-none placeholder:text-muted-foreground disabled:opacity-60" + /> + +
+

+ Preview:{" "} + + {prefix} + {draft || "your-slug"} + +

+
+ ); +} + +// ─── 4. MultiSelectField — chip selector with enum ─────────────────────────── + +export function MultiSelectField({ + label, + description, + value, + options, + onChange, +}: { + label: string; + description?: string; + value: string[]; + options: string[]; + onChange: (v: string[]) => void; +}) { + const [open, setOpen] = useState(false); + + const toggle = (opt: string) => { + if (value.includes(opt)) { + onChange(value.filter((v) => v !== opt)); + } else { + onChange([...value, opt]); + } + }; + + return ( +
+ + + + + + + {options.map((opt) => { + const checked = value.includes(opt); + return ( + + ); + })} + + +
+ ); +} + +// ─── 5. UrlField — URL with validation + favicon preview ───────────────────── + +export function UrlField({ + label, + description, + value, + onChange, +}: { + label: string; + description?: string; + value: string; + onChange: (v: string) => void; +}) { + const [local, setLocal] = useState(value); + useEffect(() => setLocal(value), [value]); + + let url: URL | null = null; + try { + url = local ? new URL(local) : null; + } catch { + url = null; + } + const isValid = Boolean(url); + const isInternal = local.startsWith("/"); + const favicon = + url?.hostname && !isInternal + ? `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32` + : null; + + return ( +
+ +
+ + {favicon ? ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( + + )} + + { + setLocal(e.target.value); + onChange(e.target.value); + }} + placeholder="https://example.com or /internal-path" + className="flex-1 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground" + /> + {local && ( + + {isInternal ? "internal" : isValid ? "valid" : "invalid"} + + )} +
+
+ ); +} + +// ─── 6. MarkdownField — split-view editor ──────────────────────────────────── + +export function MarkdownField({ + label, + description, + value, + onChange, +}: { + label: string; + description?: string; + value: string; + onChange: (v: string) => void; +}) { + const [tab, setTab] = useState<"write" | "preview">("write"); + const [local, setLocal] = useState(value); + useEffect(() => setLocal(value), [value]); + + const html = useMemo(() => { + try { + return marked.parse(local || "", { async: false }) as string; + } catch { + return ""; + } + }, [local]); + + return ( +
+ +
+
+ + + + Markdown + +
+ {tab === "write" ? ( +