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

Configure Feed

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

Add lexicon viewer components

+450
+377
app/components/ExpandableField.tsx
··· 1 + 'use client' 2 + 3 + import { useState } from 'react' 4 + import { motion, AnimatePresence } from 'motion/react' 5 + import type { LexiconDef, LexiconProperty, LexiconSchema, ParsedField } from '../lib/lexicon' 6 + import { getDescriptionOverride, getPropertyOrder } from '../data/lexicon-overrides' 7 + import { EyeIcon, EyeOffIcon, FileIcon } from 'lucide-react' 8 + 9 + // Client-side lexicon cache (passed from server) 10 + let lexiconCache: Record<string, LexiconSchema> = {} 11 + 12 + export function setLexiconCache(cache: Record<string, LexiconSchema>) { 13 + lexiconCache = cache 14 + } 15 + 16 + // Client-side ref resolution 17 + function 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 34 + interface 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) 46 + function 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) 59 + function 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) 129 + function 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 135 + function 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 + function TypeBadge({ name, type, required }: { name: string; type: string; required?: boolean }) { 166 + return ( 167 + <div className="flex flex-wrap items-center gap-2"> 168 + <span className="font-mono font-semibold text-base leading-snug tracking-tight text-base-content"> 169 + { name } 170 + </span> 171 + <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"> 172 + { type } 173 + </span> 174 + { required && ( 175 + <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"> 176 + required 177 + </span> 178 + ) } 179 + </div> 180 + ) 181 + } 182 + 183 + function RefBadge({ ref }: { ref: string }) { 184 + return ( 185 + <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"> 186 + { ref } 187 + </span> 188 + ) 189 + } 190 + 191 + interface ExpandableFieldProps { 192 + field: ParsedField; 193 + schema: LexiconSchema; 194 + } 195 + 196 + // Component to display a non-object def (array, primitive, etc.) 197 + function DefSummaryDisplay({ def }: { def: ExtendedLexiconDef }) { 198 + const summary = getDefSummary(def) 199 + 200 + return ( 201 + <div className="flex flex-col gap-1 p-4"> 202 + <div className="flex flex-wrap items-center gap-2"> 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 + { summary.type } 205 + </span> 206 + </div> 207 + 208 + {/* Union refs as badges */ } 209 + { summary.refs && summary.refs.length > 0 && ( 210 + <div className="flex flex-wrap gap-2"> 211 + { summary.refs.map((ref) => ( 212 + <RefBadge key={ ref } ref={ ref } /> 213 + )) } 214 + </div> 215 + ) } 216 + 217 + { summary.constraints.length > 0 && ( 218 + <div className="flex flex-wrap gap-4 font-mono text-sm leading-none tracking-tight text-muted"> 219 + { summary.constraints.map((constraint) => ( 220 + <span key={ constraint }>{ constraint }</span> 221 + )) } 222 + </div> 223 + ) } 224 + 225 + { summary.description && ( 226 + <p className="text-sm italic leading-snug tracking-tight text-muted"> 227 + { summary.description } 228 + </p> 229 + ) } 230 + </div> 231 + ) 232 + } 233 + 234 + // Refs that should not be expandable 235 + const NON_EXPANDABLE_REFS = [ 236 + 'com.atproto.repo.strongRef', 237 + ] 238 + 239 + // Parse ref string to get lexiconId and defName for overrides 240 + function parseRefContext(ref: string, currentSchema: LexiconSchema): { lexiconId: string; defName: string | null } { 241 + if (ref.startsWith('#')) { 242 + return { lexiconId: currentSchema.id, defName: ref.slice(1) } 243 + } 244 + if (ref.includes('#')) { 245 + const [nsid, defName] = ref.split('#') 246 + return { lexiconId: nsid, defName } 247 + } 248 + return { lexiconId: ref, defName: 'main' } 249 + } 250 + 251 + export function ExpandableField({ field, schema }: ExpandableFieldProps) { 252 + const [expanded, setExpanded] = useState(false) 253 + const isExpandable = !!field.ref && !NON_EXPANDABLE_REFS.includes(field.ref) 254 + 255 + const toggleExpand = () => { 256 + setExpanded( !expanded) 257 + } 258 + 259 + const resolvedDef = field.ref ? resolveRef(field.ref, schema) : null 260 + const refContext = field.ref ? parseRefContext(field.ref, schema) : null 261 + const hasNestedFields = resolvedDef ? defHasProperties(resolvedDef) : false 262 + const nestedFields = resolvedDef && hasNestedFields 263 + ? parseDefFields(resolvedDef, refContext?.lexiconId, refContext?.defName) 264 + : [] 265 + 266 + // Get display name for the expand button 267 + const refDisplayName = field.ref?.startsWith('#') ? field.ref.slice(1) : field.ref 268 + 269 + return ( 270 + <div className="flex flex-col gap-1 border-b border-border p-4 last:border-b-0"> 271 + <TypeBadge name={ field.name } type={ field.type } required={ field.required } /> 272 + 273 + {/* Union refs as badges */ } 274 + { field.refs && field.refs.length > 0 && ( 275 + <div className="flex flex-wrap gap-2"> 276 + { field.refs.map((ref) => ( 277 + <RefBadge key={ ref } ref={ ref } /> 278 + )) } 279 + </div> 280 + ) } 281 + 282 + { field.constraints.length > 0 && ( 283 + <div className="flex flex-wrap gap-4 font-mono text-sm leading-none tracking-tight text-muted"> 284 + { field.constraints.map((constraint) => ( 285 + <span key={ constraint }>{ constraint }</span> 286 + )) } 287 + </div> 288 + ) } 289 + 290 + { field.description && ( 291 + <p className="text-sm italic leading-snug tracking-tight text-muted"> 292 + { field.description } 293 + </p> 294 + ) } 295 + 296 + { isExpandable && ( 297 + <div className="pt-2"> 298 + <div className="flex flex-col rounded-xl border border-border bg-base-200 overflow-hidden"> 299 + {/* Header button */ } 300 + <button 301 + onClick={ toggleExpand } 302 + className={ `flex items-center gap-2 px-4 py-2.5 hover:bg-base-300 transition-colors ${ expanded ? 'border-b border-border' : '' }` } 303 + > 304 + <FileIcon className="size-4 text-base-content" /> 305 + <span className="font-medium text-base leading-snug tracking-tight text-base-content"> 306 + { refDisplayName } 307 + </span> 308 + <motion.div 309 + className="ml-auto" 310 + initial={ false } 311 + animate={{ opacity: 1 }} 312 + > 313 + { expanded ? ( 314 + <EyeOffIcon className="size-4 text-muted" /> 315 + ) : ( 316 + <EyeIcon className="size-4 text-muted" /> 317 + ) } 318 + </motion.div> 319 + </button> 320 + 321 + {/* Expandable content */ } 322 + <AnimatePresence initial={ false }> 323 + { expanded && ( 324 + <motion.div 325 + initial={{ height: 0, opacity: 0 }} 326 + animate={{ height: 'auto', opacity: 1 }} 327 + exit={{ height: 0, opacity: 0 }} 328 + transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }} 329 + className="overflow-hidden" 330 + > 331 + <div className="p-1"> 332 + <div className="flex flex-col rounded-xl border border-border bg-card overflow-hidden"> 333 + { hasNestedFields ? ( 334 + nestedFields.map((nestedField) => ( 335 + <ExpandableField 336 + key={ nestedField.name } 337 + field={ nestedField } 338 + schema={ schema } 339 + /> 340 + )) 341 + ) : resolvedDef ? ( 342 + <DefSummaryDisplay def={ resolvedDef as ExtendedLexiconDef } /> 343 + ) : null } 344 + </div> 345 + </div> 346 + </motion.div> 347 + ) } 348 + </AnimatePresence> 349 + </div> 350 + </div> 351 + ) } 352 + </div> 353 + ) 354 + } 355 + 356 + interface LexiconContentProps { 357 + schema: LexiconSchema; 358 + fields: ParsedField[]; 359 + allSchemas: Record<string, LexiconSchema>; 360 + } 361 + 362 + export function LexiconContent({ schema, fields, allSchemas }: LexiconContentProps) { 363 + // Initialize the lexicon cache on first render 364 + if (Object.keys(lexiconCache).length === 0) { 365 + setLexiconCache(allSchemas) 366 + } 367 + 368 + return ( 369 + <div className="p-1"> 370 + <div className="flex flex-col rounded-xl border border-border bg-card overflow-hidden"> 371 + { fields.map((field) => ( 372 + <ExpandableField key={ field.name } field={ field } schema={ schema } /> 373 + )) } 374 + </div> 375 + </div> 376 + ) 377 + }
+73
app/components/TabbedLexiconViewer.tsx
··· 1 + 'use client' 2 + 3 + import { useState } from 'react' 4 + import { motion, AnimatePresence } from 'motion/react' 5 + import { LexiconContent } from './ExpandableField' 6 + import type { LexiconSchema, ParsedField } from '../lib/lexicon' 7 + import { FileIcon } from 'lucide-react' 8 + 9 + interface LexiconTabConfig { 10 + nsid: string; 11 + schema: LexiconSchema; 12 + fields: ParsedField[]; 13 + } 14 + 15 + interface TabbedLexiconViewerProps { 16 + tabs: LexiconTabConfig[]; 17 + allSchemas: Record<string, LexiconSchema>; 18 + } 19 + 20 + export function TabbedLexiconViewer({ tabs, allSchemas }: TabbedLexiconViewerProps) { 21 + const [activeIndex, setActiveIndex] = useState(0) 22 + 23 + if (tabs.length === 0) { 24 + return null 25 + } 26 + 27 + const activeTab = tabs[activeIndex] 28 + 29 + return ( 30 + <div className="flex flex-col rounded-2xl border border-border bg-base-200 overflow-hidden"> 31 + {/* Tabs */ } 32 + <div className="overflow-x-auto border-b border-border"> 33 + <div className="flex"> 34 + { tabs.map((tab, index) => ( 35 + <button 36 + key={ tab.nsid } 37 + onClick={ () => setActiveIndex(index) } 38 + className="flex items-center gap-2 border-r border-border px-4 py-2.5 last:border-r-0 whitespace-nowrap" 39 + > 40 + <FileIcon 41 + className={ `size-4 ${ index === activeIndex ? 'text-base-content' : 'text-muted' }` } 42 + /> 43 + <span 44 + className={ `text-base leading-snug tracking-tight ${ 45 + index === activeIndex ? 'font-medium text-base-content' : 'text-muted' 46 + }` } 47 + > 48 + { tab.nsid } 49 + </span> 50 + </button> 51 + )) } 52 + </div> 53 + </div> 54 + 55 + {/* Content */ } 56 + <AnimatePresence mode="wait"> 57 + <motion.div 58 + key={ activeTab.nsid } 59 + initial={{ opacity: 0 }} 60 + animate={{ opacity: 1 }} 61 + exit={{ opacity: 0 }} 62 + transition={{ duration: 0.2, ease: [0.25, 0.1, 0.25, 1] }} 63 + > 64 + <LexiconContent 65 + schema={ activeTab.schema } 66 + fields={ activeTab.fields } 67 + allSchemas={ allSchemas } 68 + /> 69 + </motion.div> 70 + </AnimatePresence> 71 + </div> 72 + ) 73 + }