Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ui.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

ui.tsxBlame740 lines · 3 contributors
45e31d0Claude1/**
2 * Core UI Component Library — gluecron design system.
3 *
4 * Pure components. No raw HTML in routes. Every visual element
5 * is a composable, typed, reusable component.
6 */
7
8import type { FC, PropsWithChildren } from "hono/jsx";
9import { html } from "hono/html";
10
11// ─── Primitive Components ───────────────────────────────────────────────────
12
13/** Flex container with gap and alignment */
14export const Flex: FC<
15 PropsWithChildren<{
16 direction?: "row" | "column";
17 gap?: number;
18 align?: string;
19 justify?: string;
20 wrap?: boolean;
21 class?: string;
22 style?: string;
23 }>
24> = ({ children, direction = "row", gap = 0, align, justify, wrap, class: cls, style }) => (
25 <div
26 class={cls || ""}
27 style={`display:flex;flex-direction:${direction};${gap ? `gap:${gap}px;` : ""}${align ? `align-items:${align};` : ""}${justify ? `justify-content:${justify};` : ""}${wrap ? "flex-wrap:wrap;" : ""}${style || ""}`}
28 >
29 {children}
30 </div>
31);
32
33/** Grid container */
34export const Grid: FC<
35 PropsWithChildren<{ cols?: string; gap?: number; class?: string }>
36> = ({ children, cols = "repeat(auto-fill, minmax(340px, 1fr))", gap = 16, class: cls }) => (
37 <div class={cls || "card-grid"} style={`display:grid;grid-template-columns:${cols};gap:${gap}px;`}>
38 {children}
39 </div>
40);
41
42/** Spacer element */
43export const Spacer: FC<{ size?: number }> = ({ size = 16 }) => (
44 <div style={`height:${size}px`} />
45);
46
47/** Text with semantic styling */
48export const Text: FC<
49 PropsWithChildren<{
50 size?: number;
51 color?: string;
52 weight?: number | string;
53 mono?: boolean;
54 muted?: boolean;
55 style?: string;
56 }>
57> = ({ children, size, color, weight, mono, muted, style }) => (
58 <span
59 style={`${size ? `font-size:${size}px;` : ""}${color ? `color:${color};` : ""}${weight ? `font-weight:${weight};` : ""}${mono ? "font-family:var(--font-mono);" : ""}${muted ? "color:var(--text-muted);" : ""}${style || ""}`}
60 >
61 {children}
62 </span>
63);
64
65// ─── Buttons ────────────────────────────────────────────────────────────────
66
67export const Button: FC<
68 PropsWithChildren<{
69 variant?: "default" | "primary" | "danger" | "success" | "ghost";
70 size?: "sm" | "md" | "lg";
71 type?: "button" | "submit" | "reset";
72 disabled?: boolean;
73 formaction?: string;
74 class?: string;
75 }>
76> = ({ children, variant = "default", size = "md", type = "button", disabled, formaction, class: cls }: any) => {
77 const variantCls =
78 variant === "primary" ? " btn-primary" :
79 variant === "danger" ? " btn-danger" :
80 variant === "success" ? " btn-success" :
81 variant === "ghost" ? " btn-ghost" : "";
82 const sizeCls = size === "sm" ? " btn-sm" : size === "lg" ? " btn-lg" : "";
83 return (
84 <button
85 type={type}
86 class={`btn${variantCls}${sizeCls}${cls ? ` ${cls}` : ""}`}
87 disabled={disabled}
88 formaction={formaction}
89 >
90 {children}
91 </button>
92 );
93};
94
95export const LinkButton: FC<
96 PropsWithChildren<{
97 href: string;
98 variant?: "default" | "primary" | "danger" | "success";
99 size?: "sm" | "md";
100 }>
101> = ({ children, href, variant = "default", size = "md" }) => {
102 const variantCls = variant === "primary" ? " btn-primary" : variant === "danger" ? " btn-danger" : variant === "success" ? " btn-success" : "";
103 const sizeCls = size === "sm" ? " btn-sm" : "";
104 return (
105 <a href={href} class={`btn${variantCls}${sizeCls}`}>
106 {children}
107 </a>
108 );
109};
110
111// ─── Forms ──────────────────────────────────────────────────────────────────
112
113export const Form: FC<
114 PropsWithChildren<{
115 action: string;
116 method?: string;
117 csrfToken?: string;
118 class?: string;
2316901Claude119 id?: string;
120 encType?: string;
45e31d0Claude121 }>
2316901Claude122> = ({ children, action, method = "POST", csrfToken, class: cls, id, encType }) => (
123 <form
124 method={method.toLowerCase() as any}
125 action={action}
126 class={cls || ""}
127 id={id}
128 enctype={encType as any}
129 >
45e31d0Claude130 {csrfToken && <input type="hidden" name="_csrf" value={csrfToken} />}
131 {children}
132 </form>
133);
134
135export const FormGroup: FC<
136 PropsWithChildren<{ label?: string; htmlFor?: string; hint?: string }>
137> = ({ children, label, htmlFor, hint }) => (
138 <div class="form-group">
139 {label && <label for={htmlFor}>{label}</label>}
140 {children}
141 {hint && <Text size={12} muted>{hint}</Text>}
142 </div>
143);
144
145export const Input: FC<{
146 name: string;
147 type?: string;
148 id?: string;
149 value?: string;
150 placeholder?: string;
151 required?: boolean;
152 disabled?: boolean;
153 pattern?: string;
154 autocomplete?: string;
155 autofocus?: boolean;
bb0f894Claude156 minLength?: number;
157 maxLength?: number;
45e31d0Claude158 style?: string;
2c3ba6ecopilot-swe-agent[bot]159 "aria-label"?: string;
45e31d0Claude160}> = (props) => (
161 <input
162 type={props.type || "text"}
163 id={props.id || props.name}
164 name={props.name}
165 value={props.value}
166 placeholder={props.placeholder}
167 required={props.required}
168 disabled={props.disabled}
169 pattern={props.pattern}
170 autocomplete={props.autocomplete}
171 autofocus={props.autofocus}
bb0f894Claude172 minLength={props.minLength}
173 maxLength={props.maxLength}
45e31d0Claude174 class={props.disabled ? "input-disabled" : ""}
175 style={props.style}
2c3ba6ecopilot-swe-agent[bot]176 aria-label={props["aria-label"] || props.placeholder || props.name}
45e31d0Claude177 />
178);
179
180export const TextArea: FC<{
181 name: string;
182 id?: string;
183 rows?: number;
184 placeholder?: string;
185 required?: boolean;
186 value?: string;
187 mono?: boolean;
188 style?: string;
189}> = (props) => (
190 <textarea
191 id={props.id || props.name}
192 name={props.name}
193 rows={props.rows || 6}
194 placeholder={props.placeholder}
195 required={props.required}
196 style={`${props.mono ? "font-family:var(--font-mono);font-size:13px;" : ""}${props.style || ""}`}
197 >
198 {props.value}
199 </textarea>
200);
201
202export const Select: FC<
203 PropsWithChildren<{ name: string; id?: string; value?: string }>
204> = ({ children, name, id, value }) => (
205 <select id={id || name} name={name} value={value}>
206 {children}
207 </select>
208);
209
210// ─── Feedback Components ────────────────────────────────────────────────────
211
212export const Alert: FC<
213 PropsWithChildren<{ variant: "error" | "success" | "warning" | "info" }>
214> = ({ children, variant }) => {
215 const cls =
216 variant === "error" ? "auth-error" :
217 variant === "success" ? "auth-success" :
218 variant === "warning" ? "alert-warning" :
219 "alert-info";
220 return <div class={cls}>{children}</div>;
221};
222
223export const EmptyState: FC<
224 PropsWithChildren<{ title?: string; icon?: string }>
225> = ({ children, title, icon }) => (
226 <div class="empty-state">
227 {icon && <div style="font-size:48px;margin-bottom:12px">{icon}</div>}
228 {title && <h2>{title}</h2>}
229 {children}
230 </div>
231);
232
233export const Badge: FC<
234 PropsWithChildren<{
4fc7860Test User235 variant?: "default" | "open" | "closed" | "merged" | "success" | "danger" | "warning" | "live" | "soon" | "preview";
45e31d0Claude236 style?: string;
237 }>
238> = ({ children, variant = "default", style }) => {
239 const cls =
240 variant === "open" ? "badge-open" :
241 variant === "closed" ? "badge-closed" :
242 variant === "merged" ? "badge-merged" :
243 variant === "success" ? "badge-success" :
244 variant === "danger" ? "badge-danger" :
245 variant === "warning" ? "badge-warning" :
4fc7860Test User246 variant === "live" ? "badge-live" :
247 variant === "soon" ? "badge-soon" :
248 variant === "preview" ? "badge-preview" :
45e31d0Claude249 "badge";
250 return <span class={`issue-badge ${cls}`} style={style}>{children}</span>;
251};
252
253// ─── Card Components ────────────────────────────────────────────────────────
254
c63b860Claude255/**
256 * Card — the canonical panel primitive.
257 *
258 * Block O3: extended with `padding` and `variant` props so every "panel"
259 * style across the app maps to a single CSS contract.
260 */
261export const Card: FC<
262 PropsWithChildren<{
263 padding?: "none" | "sm" | "md" | "lg";
264 variant?: "default" | "elevated" | "gradient";
265 class?: string;
266 style?: string;
267 }>
268> = ({ children, padding, variant, class: cls, style }) => {
269 const padCls =
270 padding === "none" ? " card-p-none" :
271 padding === "sm" ? " card-p-sm" :
272 padding === "md" ? " card-p-md" :
273 padding === "lg" ? " card-p-lg" :
274 "";
275 const variantCls =
276 variant === "elevated" ? " card-elevated" :
277 variant === "gradient" ? " card-gradient" :
278 "";
279 return (
280 <div
281 class={`card${padCls}${variantCls}${cls ? ` ${cls}` : ""}`}
282 style={style}
283 >
284 {children}
285 </div>
286 );
287};
45e31d0Claude288
289export const CardMeta: FC<PropsWithChildren> = ({ children }) => (
290 <div class="card-meta">{children}</div>
291);
292
293// ─── Navigation Components ──────────────────────────────────────────────────
294
295export const TabNav: FC<{
296 tabs: Array<{ label: string; href: string; active?: boolean; count?: number }>;
297}> = ({ tabs }) => (
298 <div class="repo-nav">
299 {tabs.map((tab) => (
300 <a href={tab.href} class={tab.active ? "active" : ""}>
301 {tab.label}
302 {tab.count !== undefined && (
303 <span class="tab-count">{tab.count}</span>
304 )}
305 </a>
306 ))}
307 </div>
308);
309
310export const FilterTabs: FC<{
311 tabs: Array<{ label: string; href: string; active?: boolean }>;
312}> = ({ tabs }) => (
313 <div class="issue-tabs">
314 {tabs.map((tab) => (
315 <a href={tab.href} class={tab.active ? "active" : ""}>
316 {tab.label}
317 </a>
318 ))}
319 </div>
320);
321
322// ─── Page Layout Components ─────────────────────────────────────────────────
323
324export const PageHeader: FC<
325 PropsWithChildren<{ title: string; actions?: any }>
326> = ({ title, actions, children }) => (
327 <Flex justify="space-between" align="center" style="margin-bottom:20px">
328 <h2>{title}</h2>
329 {actions}
330 {children}
331 </Flex>
332);
333
334export const Section: FC<
335 PropsWithChildren<{ title?: string; style?: string }>
336> = ({ children, title, style }) => (
337 <div style={`margin-bottom:24px;${style || ""}`}>
338 {title && <h3 style="margin-bottom:12px">{title}</h3>}
339 {children}
340 </div>
341);
342
343export const Container: FC<
344 PropsWithChildren<{ maxWidth?: number; class?: string }>
345> = ({ children, maxWidth = 800, class: cls }) => (
346 <div class={cls || ""} style={`max-width:${maxWidth}px`}>
347 {children}
348 </div>
349);
350
351// ─── Data Display Components ────────────────────────────────────────────────
352
353export const StatGroup: FC<{
354 stats: Array<{ label: string; value: string | number; color?: string }>;
355}> = ({ stats }) => (
356 <Flex gap={24} wrap>
357 {stats.map((stat) => (
358 <div>
359 <div style={`font-size:24px;font-weight:700;${stat.color ? `color:${stat.color};` : ""}`}>
360 {stat.value}
361 </div>
362 <Text size={13} muted>{stat.label}</Text>
363 </div>
364 ))}
365 </Flex>
366);
367
368export const KeyValue: FC<{ label: string; value: string | number }> = ({
369 label,
370 value,
371}) => (
372 <Flex justify="space-between" align="center" style="padding:8px 0;border-bottom:1px solid var(--border)">
373 <Text size={14} muted>{label}</Text>
374 <Text size={14}>{String(value)}</Text>
375 </Flex>
376);
377
378export const DataTable: FC<{
379 headers: string[];
380 rows: Array<Array<string | any>>;
381 class?: string;
382}> = ({ headers, rows, class: cls }) => (
383 <table class={cls || "file-table"}>
384 <thead>
385 <tr>
386 {headers.map((h) => (
387 <th style="padding:8px 16px;text-align:left;font-size:13px;color:var(--text-muted);border-bottom:1px solid var(--border)">{h}</th>
388 ))}
389 </tr>
390 </thead>
391 <tbody>
392 {rows.map((row) => (
393 <tr>
394 {row.map((cell) => (
395 <td style="padding:8px 16px;font-size:14px">{cell}</td>
396 ))}
397 </tr>
398 ))}
399 </tbody>
400 </table>
401);
402
403// ─── List Components ────────────────────────────────────────────────────────
404
405export const ListItem: FC<
406 PropsWithChildren<{ style?: string }>
407> = ({ children, style }) => (
408 <div class="issue-item" style={style}>
409 {children}
410 </div>
411);
412
413export const List: FC<PropsWithChildren<{ class?: string }>> = ({ children, class: cls }) => (
414 <div class={cls || "issue-list"}>
415 {children}
416 </div>
417);
418
419// ─── Code Display ───────────────────────────────────────────────────────────
420
421export const CodeBlock: FC<{
422 code: string;
423 language?: string;
424 showLineNumbers?: boolean;
425}> = ({ code, showLineNumbers = true }) => {
426 const lines = code.split("\n");
427 if (lines[lines.length - 1] === "") lines.pop();
428 return (
429 <div class="blob-code">
430 <table>
431 <tbody>
432 {lines.map((line, i) => (
433 <tr>
434 {showLineNumbers && <td class="line-num">{i + 1}</td>}
435 <td class="line-content">{line}</td>
436 </tr>
437 ))}
438 </tbody>
439 </table>
440 </div>
441 );
442};
443
444export const InlineCode: FC<PropsWithChildren> = ({ children }) => (
445 <code style="font-size:12px;background:var(--bg-tertiary);padding:2px 6px;border-radius:3px;font-family:var(--font-mono)">
446 {children}
447 </code>
448);
449
450export const CopyBlock: FC<{
451 text: string;
452 label?: string;
453}> = ({ text, label }) => (
454 <Flex gap={8} align="center" class="copy-block">
455 {label && <Text size={13} muted>{label}</Text>}
456 <code
457 style="flex:1;padding:8px 12px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);font-family:var(--font-mono);font-size:13px;overflow-x:auto"
458 data-copy={text}
459 >
460 {text}
461 </code>
462 <button
463 type="button"
464 class="btn btn-sm copy-btn"
465 data-clipboard={text}
466 title="Copy to clipboard"
467 >
468 Copy
469 </button>
470 </Flex>
471);
472
473// ─── Notification Components ────────────────────────────────────────────────
474
475export const NotificationBell: FC<{ count: number; href: string }> = ({
476 count,
477 href,
478}) => (
479 <a href={href} class="notification-bell" title="Notifications">
480 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
481 <path d="M8 16a2 2 0 002-2H6a2 2 0 002 2zM8 1.918l-.797.161A4.002 4.002 0 004 6c0 .628-.134 2.197-.459 3.742-.16.767-.376 1.566-.663 2.258h10.244c-.287-.692-.502-1.49-.663-2.258C12.134 8.197 12 6.628 12 6a4.002 4.002 0 00-3.203-3.92L8 1.917zM14.22 12c.223.447.481.801.78 1H1c.299-.199.557-.553.78-1C2.68 10.2 3 6.88 3 6c0-2.42 1.72-4.44 4.005-4.901a1 1 0 111.99 0A5.002 5.002 0 0113 6c0 .88.32 4.2 1.22 6z" />
482 </svg>
483 {count > 0 && <span class="notification-count">{count > 99 ? "99+" : count}</span>}
484 </a>
485);
486
487// ─── Profile Components ─────────────────────────────────────────────────────
488
489export const Avatar: FC<{
490 name: string;
491 url?: string;
492 size?: number;
493}> = ({ name, url, size = 40 }) => {
494 if (url) {
495 return (
496 <img
497 src={url}
498 alt={name}
ea52715copilot-swe-agent[bot]499 width={size}
500 height={size}
45e31d0Claude501 style={`width:${size}px;height:${size}px;border-radius:50%;object-fit:cover`}
502 loading="lazy"
503 />
504 );
505 }
506 return (
507 <div
508 class="user-avatar"
509 style={`width:${size}px;height:${size}px;font-size:${size * 0.4}px`}
510 >
511 {name[0].toUpperCase()}
512 </div>
513 );
514};
515
516export const UserCard: FC<{
517 username: string;
518 displayName?: string | null;
519 bio?: string | null;
520 avatarUrl?: string | null;
521}> = ({ username, displayName, bio, avatarUrl }) => (
522 <div class="user-profile">
523 <Avatar name={displayName || username} url={avatarUrl || undefined} size={96} />
524 <div class="user-info">
525 <h2>{displayName || username}</h2>
526 <div class="username">@{username}</div>
527 {bio && <div class="bio">{bio}</div>}
528 </div>
529 </div>
530);
531
532// ─── Onboarding Components ──────────────────────────────────────────────────
533
534export const StepIndicator: FC<{
535 steps: Array<{ label: string; completed: boolean; active: boolean }>;
536}> = ({ steps }) => (
537 <Flex gap={0} align="center" class="step-indicator">
538 {steps.map((step, i) => (
539 <>
540 {i > 0 && <div class="step-line" data-completed={step.completed || steps[i - 1]?.completed ? "true" : "false"} />}
541 <Flex direction="column" align="center" gap={4}>
542 <div
543 class={`step-circle${step.completed ? " step-completed" : ""}${step.active ? " step-active" : ""}`}
544 >
545 {step.completed ? "\u2713" : i + 1}
546 </div>
547 <Text size={12} muted={!step.active}>{step.label}</Text>
548 </Flex>
549 </>
550 ))}
551 </Flex>
552);
553
554export const WelcomeHero: FC<
555 PropsWithChildren<{ title: string; subtitle?: string }>
556> = ({ children, title, subtitle }) => (
557 <div class="welcome-hero">
558 <h1>{title}</h1>
559 {subtitle && <p class="hero-subtitle">{subtitle}</p>}
560 {children}
561 </div>
562);
563
564export const FeatureCard: FC<{
565 icon: string;
566 title: string;
567 description: string;
568 href?: string;
569}> = ({ icon, title, description, href }) => {
570 const content = (
571 <Card class="feature-card">
572 <div class="feature-icon">{icon}</div>
573 <h3>{title}</h3>
574 <Text size={13} muted>{description}</Text>
575 </Card>
576 );
577 return href ? <a href={href} style="text-decoration:none">{content}</a> : content;
578};
579
580// ─── Search Components ──────────────────────────────────────────────────────
581
582export const SearchBar: FC<{
583 action: string;
584 value?: string;
585 placeholder?: string;
586 name?: string;
587}> = ({ action, value, placeholder = "Search...", name = "q" }) => (
588 <form method="get" action={action} style="margin-bottom:20px">
589 <Flex gap={8}>
590 <input
591 type="text"
592 name={name}
593 value={value}
594 placeholder={placeholder}
595 class="search-input"
596 autocomplete="off"
597 />
598 <Button type="submit" variant="primary">Search</Button>
599 </Flex>
600 </form>
601);
602
603export const SearchResults: FC<{
604 query: string;
605 count: number;
606}> = ({ query, count }) => (
607 <p style="font-size:14px;color:var(--text-muted);margin-bottom:16px">
608 {count} result{count !== 1 ? "s" : ""} for{" "}
609 <strong style="color:var(--text)">"{query}"</strong>
610 </p>
611);
612
613// ─── Markdown Content ───────────────────────────────────────────────────────
614
615export const MarkdownContent: FC<{ html: string }> = ({ html: htmlContent }) => (
616 <div class="markdown-body">
617 {html([htmlContent] as unknown as TemplateStringsArray)}
618 </div>
619);
620
621// ─── Comment Components ─────────────────────────────────────────────────────
622
623export const CommentBox: FC<{
624 author: string;
625 date: string | Date;
626 body: string;
627 isAi?: boolean;
628}> = ({ author, date, body, isAi }) => {
629 const dateStr = typeof date === "string" ? date : date.toISOString();
630 return (
631 <div class={`issue-comment-box${isAi ? " ai-review" : ""}`}>
632 <div class="comment-header">
633 <Flex gap={8} align="center">
634 <strong>{author}</strong>
635 {isAi && <Badge variant="default" style="font-size:11px">AI Review</Badge>}
636 <Text size={13} muted>commented {formatRelative(dateStr)}</Text>
637 </Flex>
638 </div>
639 <MarkdownContent html={body} />
640 </div>
641 );
642};
643
644export const CommentForm: FC<{
645 action: string;
646 csrfToken?: string;
647 placeholder?: string;
648 submitLabel?: string;
649 extraActions?: any;
650}> = ({ action, csrfToken, placeholder = "Leave a comment... (Markdown supported)", submitLabel = "Comment", extraActions }) => (
651 <div style="margin-top:20px">
652 <Form action={action} csrfToken={csrfToken}>
653 <FormGroup>
654 <div class="comment-editor">
655 <div class="editor-tabs">
656 <button type="button" class="editor-tab active" data-tab="write">Write</button>
657 <button type="button" class="editor-tab" data-tab="preview">Preview</button>
658 </div>
659 <TextArea
660 name="body"
661 rows={6}
662 required
663 placeholder={placeholder}
664 mono
665 />
666 <div class="editor-preview" style="display:none" />
667 </div>
668 </FormGroup>
669 <Flex gap={8}>
670 <Button type="submit" variant="primary">{submitLabel}</Button>
671 {extraActions}
672 </Flex>
673 </Form>
674 </div>
675);
676
677// ─── Tooltip Component ──────────────────────────────────────────────────────
678
679export const Tooltip: FC<PropsWithChildren<{ text: string }>> = ({
680 children,
681 text,
682}) => (
683 <span class="tooltip-wrapper" data-tooltip={text}>
684 {children}
685 </span>
686);
687
688// ─── Loading & Progress ─────────────────────────────────────────────────────
689
690export const Spinner: FC<{ size?: number }> = ({ size = 20 }) => (
691 <div
692 class="spinner"
693 style={`width:${size}px;height:${size}px`}
694 />
695);
696
697export const ProgressBar: FC<{ value: number; max?: number; color?: string }> = ({
698 value,
699 max = 100,
700 color,
701}) => (
702 <div class="progress-bar">
703 <div
704 class="progress-fill"
705 style={`width:${(value / max) * 100}%;${color ? `background:${color};` : ""}`}
706 />
707 </div>
708);
709
710// ─── Keyboard Shortcut Hint ─────────────────────────────────────────────────
711
712export const Kbd: FC<PropsWithChildren> = ({ children }) => (
713 <kbd class="kbd">{children}</kbd>
714);
715
716// ─── Utility Functions ──────────────────────────────────────────────────────
717
718export function formatRelative(dateStr: string | Date): string {
719 const date = typeof dateStr === "string" ? new Date(dateStr) : dateStr;
720 const now = new Date();
721 const diffMs = now.getTime() - date.getTime();
722 const diffMins = Math.floor(diffMs / 60000);
723 if (diffMins < 1) return "just now";
724 if (diffMins < 60) return `${diffMins}m ago`;
725 const diffHours = Math.floor(diffMins / 60);
726 if (diffHours < 24) return `${diffHours}h ago`;
727 const diffDays = Math.floor(diffHours / 24);
728 if (diffDays < 30) return `${diffDays}d ago`;
729 return date.toLocaleDateString("en-US", {
730 month: "short",
731 day: "numeric",
732 year: "numeric",
733 });
734}
735
736export function formatSize(bytes: number): string {
737 if (bytes < 1024) return `${bytes} B`;
738 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
739 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
740}