Standard.site landing page built in Next.js
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 409 lines 16 kB view raw
1'use client' 2 3import { useState } from 'react' 4import { motion, AnimatePresence } from 'motion/react' 5import type { LexiconDef, LexiconProperty, LexiconSchema, ParsedField } from '@/app/lib/lexicon' 6import { getDescriptionOverride, getPropertyOrder } from '@/app/data/lexicon-overrides' 7import { EyeIcon, EyeOffIcon, FileIcon } from 'lucide-react' 8 9// Client-side lexicon cache (passed from server) 10let lexiconCache: Record<string, LexiconSchema> = {} 11 12export function setLexiconCache(cache: Record<string, LexiconSchema>) { 13 lexiconCache = cache 14} 15 16// Client-side ref resolution 17function resolveRef(ref: string, currentSchema: LexiconSchema): LexiconDef | null { 18 if (ref.startsWith('#')) { 19 const defName = ref.slice(1) 20 return currentSchema.defs[defName] ?? null 21 } 22 23 if (ref.includes('#')) { 24 const [nsid, defName] = ref.split('#') 25 const schema = lexiconCache[nsid] 26 return schema?.defs[defName] ?? null 27 } 28 29 const schema = lexiconCache[ref] 30 return schema?.defs.main ?? null 31} 32 33// Extended def type to handle array items 34interface ExtendedLexiconDef extends LexiconDef { 35 items?: { 36 type: string; 37 refs?: string[]; 38 maxLength?: number; 39 maxGraphemes?: number; 40 }; 41 minimum?: number; 42 maximum?: number; 43} 44 45// Helper to get the object schema from a def (handles record types) 46function getObjectSchema(def: LexiconDef): { properties?: Record<string, LexiconProperty>; required?: string[] } | null { 47 // For record types, the object schema is nested inside 'record' 48 if (def.type === 'record' && def.record) { 49 return def.record 50 } 51 // For object types, properties are directly on the def 52 if (def.type === 'object' && def.properties) { 53 return def 54 } 55 return null 56} 57 58// Parse a LexiconDef into fields (for object types with properties) 59function parseDefFields( 60 def: LexiconDef, 61 lexiconId?: string, 62 defName?: string | null 63): ParsedField[] { 64 const schema = getObjectSchema(def) 65 if (!schema?.properties) { 66 return [] 67 } 68 69 const requiredFields = new Set(schema.required || []) 70 const propertyOrder = lexiconId ? getPropertyOrder(lexiconId, defName ?? null) : undefined 71 72 // Sort entries by custom order if defined 73 let entries = Object.entries(schema.properties) 74 if (propertyOrder) { 75 entries = entries.sort((a, b) => { 76 const indexA = propertyOrder.indexOf(a[0]) 77 const indexB = propertyOrder.indexOf(b[0]) 78 // Properties not in order list go to the end 79 const orderA = indexA === -1 ? Infinity : indexA 80 const orderB = indexB === -1 ? Infinity : indexB 81 return orderA - orderB 82 }) 83 } 84 85 return entries.map(([name, prop]) => { 86 const constraints: string[] = [] 87 88 if (prop.maxLength) constraints.push(`maxLength: ${prop.maxLength}`) 89 if (prop.maxGraphemes) constraints.push(`maxGraphemes: ${prop.maxGraphemes}`) 90 if (prop.maxSize) { 91 const bytes = prop.maxSize 92 const formatted = bytes >= 1_000_000 ? `${bytes / 1_000_000}MB` : bytes >= 1_000 ? `${bytes / 1_000}KB` : `${bytes}B` 93 constraints.push(`maxSize: ${formatted}`) 94 } 95 if (prop.accept) constraints.push(`accept: [${prop.accept.join(', ')}]`) 96 97 if (prop.items) { 98 if (prop.items.maxLength) constraints.push(`items.maxLength: ${prop.items.maxLength}`) 99 if (prop.items.maxGraphemes) constraints.push(`items.maxGraphemes: ${prop.items.maxGraphemes}`) 100 } 101 102 let type = prop.type 103 if (prop.format) type = `${prop.type}:${prop.format}` 104 if (prop.ref) type = prop.ref 105 if (prop.type === 'array' && prop.items) { 106 type = `array<${prop.items.type}>` 107 } 108 109 const refs = (prop as { refs?: string[] }).refs 110 111 // Check for description override 112 const description = lexiconId 113 ? getDescriptionOverride(lexiconId, defName ?? null, name) ?? prop.description 114 : prop.description 115 116 return { 117 name, 118 type, 119 required: requiredFields.has(name), 120 description, 121 constraints, 122 ref: prop.ref, 123 refs, 124 } 125 }) 126} 127 128// Check if def has properties (is an object type with fields) 129function defHasProperties(def: LexiconDef): boolean { 130 const schema = getObjectSchema(def) 131 return !!schema?.properties && Object.keys(schema.properties).length > 0 132} 133 134// Get summary info for non-object defs 135function getDefSummary(def: ExtendedLexiconDef): { 136 type: string; 137 description?: string; 138 constraints: string[]; 139 refs?: string[] 140} { 141 const constraints: string[] = [] 142 let type = def.type 143 144 if (def.type === 'array' && def.items) { 145 type = `array<${def.items.type}>` 146 if (def.items.maxLength) constraints.push(`items.maxLength: ${def.items.maxLength}`) 147 if (def.items.maxGraphemes) constraints.push(`items.maxGraphemes: ${def.items.maxGraphemes}`) 148 } 149 150 if ((def as ExtendedLexiconDef).minimum !== undefined) { 151 constraints.push(`minimum: ${(def as ExtendedLexiconDef).minimum}`) 152 } 153 if ((def as ExtendedLexiconDef).maximum !== undefined) { 154 constraints.push(`maximum: ${(def as ExtendedLexiconDef).maximum}`) 155 } 156 157 return { 158 type, 159 description: def.description, 160 constraints, 161 refs: def.items?.refs, 162 } 163} 164 165// Helper to parse description with inline code (backticks) 166function parseInlineCode(text: string): React.ReactNode { 167 const parts: React.ReactNode[] = [] 168 let lastIndex = 0 169 const regex = /`([^`]+)`/g 170 let match 171 172 while ((match = regex.exec(text)) !== null) { 173 // Add text before the match 174 if (match.index > lastIndex) { 175 parts.push(text.slice(lastIndex, match.index)) 176 } 177 // Add the code part with styling 178 parts.push( 179 <code 180 key={match.index} 181 className="rounded bg-base-200 px-1 mx-0.5 font-mono text-sm text-base-content italic" 182 > 183 {match[1]} 184 </code> 185 ) 186 lastIndex = match.index + match[0].length 187 } 188 189 // Add remaining text 190 if (lastIndex < text.length) { 191 parts.push(text.slice(lastIndex)) 192 } 193 194 return parts.length > 0 ? parts : text 195} 196 197function TypeBadge({ name, type, required }: { name: string; type: string; required?: boolean }) { 198 return ( 199 <div className="flex flex-wrap items-center gap-2"> 200 <span className="font-mono font-semibold text-base leading-snug tracking-tight text-base-content"> 201 {name} 202 </span> 203 <span className="rounded border border-sky-200 bg-sky-100 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-100 px-1 py-0.5 font-mono font-medium text-sm leading-none tracking-tight"> 204 {type} 205 </span> 206 {required && ( 207 <span className="rounded border border-red-200 bg-red-100 text-red-800 dark:border-red-900 dark:bg-red-950 dark:text-red-100 px-1 py-0.5 font-mono font-medium text-sm leading-none tracking-tight"> 208 required 209 </span> 210 )} 211 </div> 212 ) 213} 214 215function RefBadge({ ref }: { ref: string }) { 216 return ( 217 <span className="rounded border border-emerald-200 bg-emerald-100 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-100 px-1 py-0.5 font-mono font-medium text-sm leading-none tracking-tight"> 218 {ref} 219 </span> 220 ) 221} 222 223interface ExpandableFieldProps { 224 field: ParsedField; 225 schema: LexiconSchema; 226} 227 228// Component to display a non-object def (array, primitive, etc.) 229function DefSummaryDisplay({ def }: { def: ExtendedLexiconDef }) { 230 const summary = getDefSummary(def) 231 232 return ( 233 <div className="flex flex-col gap-1 p-4"> 234 <div className="flex flex-wrap items-center gap-2"> 235 <span className="rounded border border-sky-200 bg-sky-100 text-sky-800 dark:border-sky-900 dark:bg-sky-950 dark:text-sky-100 px-1 py-0.5 font-mono font-medium text-sm leading-none tracking-tight"> 236 {summary.type} 237 </span> 238 </div> 239 240 {/* Union refs as badges */} 241 {summary.refs && summary.refs.length > 0 && ( 242 <div className="flex flex-wrap gap-2"> 243 {summary.refs.map((ref) => ( 244 <RefBadge key={ref} ref={ref} /> 245 ))} 246 </div> 247 )} 248 249 {summary.constraints.length > 0 && ( 250 <div className="flex flex-wrap gap-4 font-mono text-sm leading-none tracking-tight text-muted"> 251 {summary.constraints.map((constraint) => ( 252 <span key={constraint}>{constraint}</span> 253 ))} 254 </div> 255 )} 256 257 {summary.description && ( 258 <p className="text-sm italic leading-snug tracking-tight text-muted"> 259 {parseInlineCode(summary.description)} 260 </p> 261 )} 262 </div> 263 ) 264} 265 266// Refs that should not be expandable 267const NON_EXPANDABLE_REFS = [ 268 'com.atproto.repo.strongRef', 269] 270 271// Parse ref string to get lexiconId and defName for overrides 272function parseRefContext(ref: string, currentSchema: LexiconSchema): { lexiconId: string; defName: string | null } { 273 if (ref.startsWith('#')) { 274 return { lexiconId: currentSchema.id, defName: ref.slice(1) } 275 } 276 if (ref.includes('#')) { 277 const [nsid, defName] = ref.split('#') 278 return { lexiconId: nsid, defName } 279 } 280 return { lexiconId: ref, defName: 'main' } 281} 282 283export function ExpandableField({ field, schema }: ExpandableFieldProps) { 284 const [expanded, setExpanded] = useState(false) 285 const isExpandable = !!field.ref && !NON_EXPANDABLE_REFS.includes(field.ref) 286 287 const toggleExpand = () => { 288 setExpanded(!expanded) 289 } 290 291 const resolvedDef = field.ref ? resolveRef(field.ref, schema) : null 292 const refContext = field.ref ? parseRefContext(field.ref, schema) : null 293 const hasNestedFields = resolvedDef ? defHasProperties(resolvedDef) : false 294 const nestedFields = resolvedDef && hasNestedFields 295 ? parseDefFields(resolvedDef, refContext?.lexiconId, refContext?.defName) 296 : [] 297 298 // Get display name for the expand button 299 const refDisplayName = field.ref?.startsWith('#') ? field.ref.slice(1) : field.ref 300 301 return ( 302 <div className="flex flex-col gap-1 border-b border-border p-4 last:border-b-0"> 303 <TypeBadge name={field.name} type={field.type} required={field.required} /> 304 305 {/* Union refs as badges */} 306 {field.refs && field.refs.length > 0 && ( 307 <div className="flex flex-wrap gap-2"> 308 {field.refs.map((ref) => ( 309 <RefBadge key={ref} ref={ref} /> 310 ))} 311 </div> 312 )} 313 314 {field.constraints.length > 0 && ( 315 <div className="flex flex-wrap gap-4 font-mono text-sm leading-none tracking-tight text-muted"> 316 {field.constraints.map((constraint) => ( 317 <span key={constraint}>{constraint}</span> 318 ))} 319 </div> 320 )} 321 322 {field.description && ( 323 <p className="text-sm italic leading-snug tracking-tight text-muted"> 324 {parseInlineCode(field.description)} 325 </p> 326 )} 327 328 {isExpandable && ( 329 <div className="pt-2"> 330 <div className="flex flex-col rounded-xl border border-border bg-base-200 overflow-hidden"> 331 {/* Header button */} 332 <button 333 onClick={toggleExpand} 334 className={`flex items-center gap-2 px-4 py-2.5 hover:bg-base-300 transition-colors hover:cursor-pointer ${expanded ? 'border-b border-border' : ''}`} 335 > 336 <FileIcon className="size-4 text-base-content" /> 337 <span className="font-medium text-base leading-snug tracking-tight text-base-content"> 338 {refDisplayName} 339 </span> 340 <motion.div 341 className="ml-auto" 342 initial={false} 343 animate={{ opacity: 1 }} 344 > 345 {expanded ? ( 346 <EyeOffIcon className="size-4 text-muted" /> 347 ) : ( 348 <EyeIcon className="size-4 text-muted" /> 349 )} 350 </motion.div> 351 </button> 352 353 {/* Expandable content */} 354 <AnimatePresence initial={false}> 355 {expanded && ( 356 <motion.div 357 initial={{ height: 0, opacity: 0 }} 358 animate={{ height: 'auto', opacity: 1 }} 359 exit={{ height: 0, opacity: 0 }} 360 transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }} 361 className="overflow-hidden" 362 > 363 <div className="p-1"> 364 <div className="flex flex-col rounded-xl border border-border bg-card overflow-hidden"> 365 {hasNestedFields ? ( 366 nestedFields.map((nestedField) => ( 367 <ExpandableField 368 key={nestedField.name} 369 field={nestedField} 370 schema={schema} 371 /> 372 )) 373 ) : resolvedDef ? ( 374 <DefSummaryDisplay def={resolvedDef as ExtendedLexiconDef} /> 375 ) : null} 376 </div> 377 </div> 378 </motion.div> 379 )} 380 </AnimatePresence> 381 </div> 382 </div> 383 )} 384 </div> 385 ) 386} 387 388interface LexiconContentProps { 389 schema: LexiconSchema; 390 fields: ParsedField[]; 391 allSchemas: Record<string, LexiconSchema>; 392} 393 394export function LexiconContent({ schema, fields, allSchemas }: LexiconContentProps) { 395 // Initialize the lexicon cache on first render 396 if (Object.keys(lexiconCache).length === 0) { 397 setLexiconCache(allSchemas) 398 } 399 400 return ( 401 <div className="p-1"> 402 <div className="flex flex-col rounded-xl border border-border bg-card overflow-hidden"> 403 {fields.map((field) => ( 404 <ExpandableField key={field.name} field={field} schema={schema} /> 405 ))} 406 </div> 407 </div> 408 ) 409}