11import { format as formatDate } from "date-fns" ;
2- import * as LucideIcons from "lucide-react" ;
32import {
3+ Award ,
4+ Bell ,
5+ Bike ,
6+ Bookmark ,
7+ Bus ,
48 Calendar as CalendarIcon ,
9+ Camera ,
10+ Car ,
511 Check ,
612 ChevronDown ,
713 Clock ,
14+ Cloud ,
15+ Coffee ,
16+ Cookie ,
17+ CreditCard ,
18+ Download ,
819 Eye ,
20+ EyeOff ,
921 FileText ,
22+ Flag ,
23+ Gift ,
24+ Globe ,
25+ Headphones ,
26+ Heart ,
27+ Home ,
28+ Image ,
1029 Link as LinkIcon ,
1130 Lock ,
1231 type LucideIcon ,
32+ Mail ,
33+ MapPin ,
34+ MessageCircle ,
35+ Mic ,
36+ Minus ,
37+ Moon ,
38+ Music ,
39+ Package ,
40+ Phone ,
41+ Pizza ,
42+ Plane ,
43+ Plus ,
1344 Search ,
45+ Send ,
46+ Settings ,
47+ Share ,
48+ Shield ,
49+ ShoppingBag ,
50+ ShoppingCart ,
51+ Smile ,
52+ Star ,
53+ Sun ,
54+ Tag ,
55+ ThumbsUp ,
56+ Train ,
57+ Trophy ,
58+ Truck ,
1459 Unlock ,
60+ Upload ,
61+ User ,
62+ Users ,
63+ Utensils ,
64+ Video ,
1565 X ,
66+ Zap ,
1667} from "lucide-react" ;
17- import { marked } from "marked" ;
18- import { useEffect , useMemo , useState } from "react" ;
68+ import { useEffect , useState } from "react" ;
1969import { Button } from "@/components/ui/button.tsx" ;
2070import { Calendar } from "@/components/ui/calendar.tsx" ;
2171import { Input } from "@/components/ui/input.tsx" ;
@@ -45,16 +95,7 @@ export function SwitchField({
4595} ) {
4696 return (
4797 < div className = "flex items-start justify-between gap-3 rounded-lg border bg-muted/10 px-4 py-3" >
48- < div className = "min-w-0 flex-1 space-y-0.5" >
49- < span className = "block text-sm font-medium text-foreground" >
50- { label }
51- </ span >
52- { description && (
53- < span className = "block text-xs leading-snug text-muted-foreground" >
54- { description }
55- </ span >
56- ) }
57- </ div >
98+ < FieldLabel label = { label } description = { description } className = "flex-1" />
5899 < Switch
59100 checked = { value }
60101 onCheckedChange = { onChange }
@@ -313,17 +354,14 @@ export function UrlField({
313354 value : string ;
314355 onChange : ( v : string ) => void ;
315356} ) {
316- const [ local , setLocal ] = useState ( value ) ;
317- useEffect ( ( ) => setLocal ( value ) , [ value ] ) ;
318-
319357 let url : URL | null = null ;
320358 try {
321- url = local ? new URL ( local ) : null ;
359+ url = value ? new URL ( value ) : null ;
322360 } catch {
323361 url = null ;
324362 }
325363 const isValid = Boolean ( url ) ;
326- const isInternal = local . startsWith ( "/" ) ;
364+ const isInternal = value . startsWith ( "/" ) ;
327365 const favicon =
328366 url ?. hostname && ! isInternal
329367 ? `https://www.google.com/s2/favicons?domain=${ url . hostname } &sz=32`
@@ -348,15 +386,12 @@ export function UrlField({
348386 ) }
349387 </ span >
350388 < input
351- value = { local }
352- onChange = { ( e ) => {
353- setLocal ( e . target . value ) ;
354- onChange ( e . target . value ) ;
355- } }
389+ value = { value }
390+ onChange = { ( e ) => onChange ( e . target . value ) }
356391 placeholder = "https://example.com or /internal-path"
357392 className = "flex-1 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground"
358393 />
359- { local && (
394+ { value && (
360395 < span
361396 className = { cn (
362397 "mr-3 shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium" ,
@@ -377,6 +412,20 @@ export function UrlField({
377412
378413// ─── 6. MarkdownField — split-view editor ────────────────────────────────────
379414
415+ // Marked is only needed when the preview tab is open. Lazy-load and cache.
416+ let markedParser : ( ( src : string ) => string ) | null = null ;
417+ let markedLoading : Promise < void > | null = null ;
418+
419+ function loadMarked ( ) : Promise < void > {
420+ if ( markedParser ) return Promise . resolve ( ) ;
421+ if ( ! markedLoading ) {
422+ markedLoading = import ( "marked" ) . then ( ( { marked } ) => {
423+ markedParser = ( s ) => marked . parse ( s , { async : false } ) as string ;
424+ } ) ;
425+ }
426+ return markedLoading ;
427+ }
428+
380429export function MarkdownField ( {
381430 label,
382431 description,
@@ -389,16 +438,27 @@ export function MarkdownField({
389438 onChange : ( v : string ) => void ;
390439} ) {
391440 const [ tab , setTab ] = useState < "write" | "preview" > ( "write" ) ;
392- const [ local , setLocal ] = useState ( value ) ;
393- useEffect ( ( ) => setLocal ( value ) , [ value ] ) ;
441+ const [ , forceUpdate ] = useState ( 0 ) ;
394442
395- const html = useMemo ( ( ) => {
443+ useEffect ( ( ) => {
444+ if ( tab !== "preview" || markedParser ) return ;
445+ let cancelled = false ;
446+ loadMarked ( ) . then ( ( ) => {
447+ if ( ! cancelled ) forceUpdate ( ( n ) => n + 1 ) ;
448+ } ) ;
449+ return ( ) => {
450+ cancelled = true ;
451+ } ;
452+ } , [ tab ] ) ;
453+
454+ const html = ( ( ) => {
455+ if ( ! markedParser ) return null ;
396456 try {
397- return marked . parse ( local || "" , { async : false } ) as string ;
457+ return markedParser ( value || "" ) ;
398458 } catch {
399459 return "" ;
400460 }
401- } , [ local ] ) ;
461+ } ) ( ) ;
402462
403463 return (
404464 < div className = "space-y-2" >
@@ -437,16 +497,17 @@ export function MarkdownField({
437497 </ div >
438498 { tab === "write" ? (
439499 < Textarea
440- value = { local }
441- onChange = { ( e ) => {
442- setLocal ( e . target . value ) ;
443- onChange ( e . target . value ) ;
444- } }
500+ value = { value }
501+ onChange = { ( e ) => onChange ( e . target . value ) }
445502 rows = { 10 }
446503 spellCheck = { false }
447504 placeholder = "# Hello world Type some **markdown** here…"
448505 className = "min-h-[unset] resize-y rounded-none border-0 text-xs leading-relaxed shadow-none focus-visible:ring-0"
449506 />
507+ ) : html === null ? (
508+ < div className = "flex min-h-[200px] items-center justify-center text-xs text-muted-foreground" >
509+ Loading preview…
510+ </ div >
450511 ) : (
451512 < div
452513 className = "prose prose-sm min-h-[200px] max-w-none px-4 py-3 text-sm prose-headings:font-semibold prose-headings:mt-3 prose-headings:mb-1.5 prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-a:text-primary prose-a:underline prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-xs"
@@ -784,7 +845,6 @@ function parseTime(value: string): { h: string; m: string } | null {
784845}
785846
786847function normalizeTimeInput ( raw : string ) : string | null {
787- // Accept "9", "9:3", "09:30", "1234" → "HH:MM" if valid; null otherwise.
788848 const cleaned = raw . replace ( / [ ^ \d : ] / g, "" ) ;
789849 if ( ! cleaned ) return null ;
790850 let h : string ;
@@ -842,7 +902,6 @@ export function TimeField({
842902 onChange ( normalized ) ;
843903 setDraft ( normalized ) ;
844904 } else {
845- // invalid — revert to last good value
846905 setDraft ( value ) ;
847906 }
848907 } ;
@@ -970,68 +1029,69 @@ function TimeColumn({
9701029
9711030// ─── 11. IconField — pick from Lucide library ───────────────────────────────
9721031
973- const POPULAR_ICONS : string [ ] = [
974- "Home" ,
975- "Search" ,
976- "User" ,
977- "Users" ,
978- "Settings" ,
979- "Heart" ,
980- "Star" ,
981- "Bell" ,
982- "Mail" ,
983- "Phone" ,
984- "MapPin" ,
985- "Calendar" ,
986- "Clock" ,
987- "ShoppingCart" ,
988- "ShoppingBag" ,
989- "Package" ,
990- "Truck" ,
991- "CreditCard" ,
992- "Tag" ,
993- "Gift" ,
994- "Zap" ,
995- "Award" ,
996- "Trophy" ,
997- "Flag" ,
998- "Bookmark" ,
999- "Camera" ,
1000- "Image" ,
1001- "Video" ,
1002- "Music" ,
1003- "Headphones" ,
1004- "Mic" ,
1005- "Globe" ,
1006- "Link" ,
1007- "Share" ,
1008- "Download" ,
1009- "Upload" ,
1010- "Cloud" ,
1011- "Lock" ,
1012- "Shield" ,
1013- "Eye" ,
1014- "EyeOff" ,
1015- "Sun" ,
1016- "Moon" ,
1017- "Coffee" ,
1018- "Cookie" ,
1019- "Pizza" ,
1020- "Utensils" ,
1021- "Plane" ,
1022- "Car" ,
1023- "Bike" ,
1024- "Train" ,
1025- "Bus" ,
1026- "Smile" ,
1027- "ThumbsUp" ,
1028- "MessageCircle" ,
1029- "Send" ,
1030- "Plus" ,
1031- "Minus" ,
1032- "Check" ,
1033- "X" ,
1034- ] ;
1032+ const ICON_MAP : Record < string , LucideIcon > = {
1033+ Award,
1034+ Bell,
1035+ Bike,
1036+ Bookmark,
1037+ Bus,
1038+ Calendar : CalendarIcon ,
1039+ Camera,
1040+ Car,
1041+ Check,
1042+ Clock,
1043+ Cloud,
1044+ Coffee,
1045+ Cookie,
1046+ CreditCard,
1047+ Download,
1048+ Eye,
1049+ EyeOff,
1050+ Flag,
1051+ Gift,
1052+ Globe,
1053+ Headphones,
1054+ Heart,
1055+ Home,
1056+ Image,
1057+ Link : LinkIcon ,
1058+ Lock,
1059+ Mail,
1060+ MapPin,
1061+ MessageCircle,
1062+ Mic,
1063+ Minus,
1064+ Moon,
1065+ Music,
1066+ Package,
1067+ Phone,
1068+ Pizza,
1069+ Plane,
1070+ Plus,
1071+ Search,
1072+ Send,
1073+ Settings,
1074+ Share,
1075+ Shield,
1076+ ShoppingBag,
1077+ ShoppingCart,
1078+ Smile,
1079+ Star,
1080+ Sun,
1081+ Tag,
1082+ ThumbsUp,
1083+ Train,
1084+ Trophy,
1085+ Truck,
1086+ Upload,
1087+ User,
1088+ Users,
1089+ Utensils,
1090+ Video,
1091+ X,
1092+ Zap,
1093+ } ;
1094+ const POPULAR_ICONS = Object . keys ( ICON_MAP ) ;
10351095
10361096export function IconField ( {
10371097 label,
@@ -1047,9 +1107,7 @@ export function IconField({
10471107 const [ open , setOpen ] = useState ( false ) ;
10481108 const [ query , setQuery ] = useState ( "" ) ;
10491109
1050- const Selected = ( LucideIcons as unknown as Record < string , LucideIcon > ) [
1051- value
1052- ] ;
1110+ const Selected = ICON_MAP [ value ] ;
10531111 const filtered = query
10541112 ? POPULAR_ICONS . filter ( ( n ) => n . toLowerCase ( ) . includes ( query . toLowerCase ( ) ) )
10551113 : POPULAR_ICONS ;
@@ -1093,9 +1151,7 @@ export function IconField({
10931151 </ div >
10941152 < div className = "grid max-h-64 grid-cols-7 gap-1 overflow-y-auto p-2" >
10951153 { filtered . map ( ( name ) => {
1096- const Icon = (
1097- LucideIcons as unknown as Record < string , LucideIcon >
1098- ) [ name ] ;
1154+ const Icon = ICON_MAP [ name ] ;
10991155 if ( ! Icon ) return null ;
11001156 const active = value === name ;
11011157 return (
0 commit comments