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..14f30bb --- /dev/null +++ b/web/tools/file-explorer/cms-form-extras.tsx @@ -0,0 +1,1116 @@ +import { format as formatDate } from "date-fns"; +import { + Award, + Bell, + Bike, + Bookmark, + Bus, + Calendar as CalendarIcon, + Camera, + Car, + Check, + ChevronDown, + Clock, + Cloud, + Coffee, + Cookie, + CreditCard, + Download, + Eye, + EyeOff, + FileText, + Flag, + Gift, + Globe, + Headphones, + Heart, + Home, + Image, + Link as LinkIcon, + Lock, + type LucideIcon, + Mail, + MapPin, + MessageCircle, + Mic, + Minus, + Moon, + Music, + Package, + Phone, + Pizza, + Plane, + Plus, + Search, + Send, + Settings, + Share, + Shield, + ShoppingBag, + ShoppingCart, + Smile, + Star, + Sun, + Tag, + ThumbsUp, + Train, + Trophy, + Truck, + Upload, + User, + Users, + Utensils, + Video, + X, + Zap, +} from "lucide-react"; +import { useEffect, 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 ( +
+ + +
+ ); +} + +// ─── 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" + /> +
+
+ ); +} + +// ─── 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; +}) { + let url: URL | null = null; + try { + url = value ? new URL(value) : null; + } catch { + url = null; + } + const isValid = Boolean(url); + const isInternal = value.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"; + }} + /> + ) : ( + + )} + + 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" + /> + {value && ( + + {isInternal ? "internal" : isValid ? "valid" : "invalid"} + + )} +
+
+ ); +} + +// ─── 6. MarkdownField — split-view editor ──────────────────────────────────── + +// Marked is only needed when the preview tab is open. Lazy-load and cache. +let markedParser: ((src: string) => string) | null = null; +let markedLoading: Promise | null = null; + +function loadMarked(): Promise { + if (markedParser) return Promise.resolve(); + if (!markedLoading) { + markedLoading = import("marked").then(({ marked }) => { + markedParser = (s) => marked.parse(s, { async: false }) as string; + }); + } + return markedLoading; +} + +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 [, forceUpdate] = useState(0); + + useEffect(() => { + if (tab !== "preview" || markedParser) return; + let cancelled = false; + loadMarked().then(() => { + if (!cancelled) forceUpdate((n) => n + 1); + }); + return () => { + cancelled = true; + }; + }, [tab]); + + const html = (() => { + if (!markedParser) return null; + try { + return markedParser(value || ""); + } catch { + return ""; + } + })(); + + return ( +
+ +
+
+ + + + Markdown + +
+ {tab === "write" ? ( +