Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame697 lines · 1 contributor
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;
119 }>
120> = ({ children, action, method = "POST", csrfToken, class: cls }) => (
121 <form method={method.toLowerCase() as any} action={action} class={cls || ""}>
122 {csrfToken && <input type="hidden" name="_csrf" value={csrfToken} />}
123 {children}
124 </form>
125);
126
127export const FormGroup: FC<
128 PropsWithChildren<{ label?: string; htmlFor?: string; hint?: string }>
129> = ({ children, label, htmlFor, hint }) => (
130 <div class="form-group">
131 {label && <label for={htmlFor}>{label}</label>}
132 {children}
133 {hint && <Text size={12} muted>{hint}</Text>}
134 </div>
135);
136
137export const Input: FC<{
138 name: string;
139 type?: string;
140 id?: string;
141 value?: string;
142 placeholder?: string;
143 required?: boolean;
144 disabled?: boolean;
145 pattern?: string;
146 autocomplete?: string;
147 autofocus?: boolean;
148 style?: string;
149}> = (props) => (
150 <input
151 type={props.type || "text"}
152 id={props.id || props.name}
153 name={props.name}
154 value={props.value}
155 placeholder={props.placeholder}
156 required={props.required}
157 disabled={props.disabled}
158 pattern={props.pattern}
159 autocomplete={props.autocomplete}
160 autofocus={props.autofocus}
161 class={props.disabled ? "input-disabled" : ""}
162 style={props.style}
163 />
164);
165
166export const TextArea: FC<{
167 name: string;
168 id?: string;
169 rows?: number;
170 placeholder?: string;
171 required?: boolean;
172 value?: string;
173 mono?: boolean;
174 style?: string;
175}> = (props) => (
176 <textarea
177 id={props.id || props.name}
178 name={props.name}
179 rows={props.rows || 6}
180 placeholder={props.placeholder}
181 required={props.required}
182 style={`${props.mono ? "font-family:var(--font-mono);font-size:13px;" : ""}${props.style || ""}`}
183 >
184 {props.value}
185 </textarea>
186);
187
188export const Select: FC<
189 PropsWithChildren<{ name: string; id?: string; value?: string }>
190> = ({ children, name, id, value }) => (
191 <select id={id || name} name={name} value={value}>
192 {children}
193 </select>
194);
195
196// ─── Feedback Components ────────────────────────────────────────────────────
197
198export const Alert: FC<
199 PropsWithChildren<{ variant: "error" | "success" | "warning" | "info" }>
200> = ({ children, variant }) => {
201 const cls =
202 variant === "error" ? "auth-error" :
203 variant === "success" ? "auth-success" :
204 variant === "warning" ? "alert-warning" :
205 "alert-info";
206 return <div class={cls}>{children}</div>;
207};
208
209export const EmptyState: FC<
210 PropsWithChildren<{ title?: string; icon?: string }>
211> = ({ children, title, icon }) => (
212 <div class="empty-state">
213 {icon && <div style="font-size:48px;margin-bottom:12px">{icon}</div>}
214 {title && <h2>{title}</h2>}
215 {children}
216 </div>
217);
218
219export const Badge: FC<
220 PropsWithChildren<{
221 variant?: "default" | "open" | "closed" | "merged" | "success" | "danger" | "warning";
222 style?: string;
223 }>
224> = ({ children, variant = "default", style }) => {
225 const cls =
226 variant === "open" ? "badge-open" :
227 variant === "closed" ? "badge-closed" :
228 variant === "merged" ? "badge-merged" :
229 variant === "success" ? "badge-success" :
230 variant === "danger" ? "badge-danger" :
231 variant === "warning" ? "badge-warning" :
232 "badge";
233 return <span class={`issue-badge ${cls}`} style={style}>{children}</span>;
234};
235
236// ─── Card Components ────────────────────────────────────────────────────────
237
238export const Card: FC<PropsWithChildren<{ class?: string; style?: string }>> = ({
239 children,
240 class: cls,
241 style,
242}) => (
243 <div class={`card${cls ? ` ${cls}` : ""}`} style={style}>
244 {children}
245 </div>
246);
247
248export const CardMeta: FC<PropsWithChildren> = ({ children }) => (
249 <div class="card-meta">{children}</div>
250);
251
252// ─── Navigation Components ──────────────────────────────────────────────────
253
254export const TabNav: FC<{
255 tabs: Array<{ label: string; href: string; active?: boolean; count?: number }>;
256}> = ({ tabs }) => (
257 <div class="repo-nav">
258 {tabs.map((tab) => (
259 <a href={tab.href} class={tab.active ? "active" : ""}>
260 {tab.label}
261 {tab.count !== undefined && (
262 <span class="tab-count">{tab.count}</span>
263 )}
264 </a>
265 ))}
266 </div>
267);
268
269export const FilterTabs: FC<{
270 tabs: Array<{ label: string; href: string; active?: boolean }>;
271}> = ({ tabs }) => (
272 <div class="issue-tabs">
273 {tabs.map((tab) => (
274 <a href={tab.href} class={tab.active ? "active" : ""}>
275 {tab.label}
276 </a>
277 ))}
278 </div>
279);
280
281// ─── Page Layout Components ─────────────────────────────────────────────────
282
283export const PageHeader: FC<
284 PropsWithChildren<{ title: string; actions?: any }>
285> = ({ title, actions, children }) => (
286 <Flex justify="space-between" align="center" style="margin-bottom:20px">
287 <h2>{title}</h2>
288 {actions}
289 {children}
290 </Flex>
291);
292
293export const Section: FC<
294 PropsWithChildren<{ title?: string; style?: string }>
295> = ({ children, title, style }) => (
296 <div style={`margin-bottom:24px;${style || ""}`}>
297 {title && <h3 style="margin-bottom:12px">{title}</h3>}
298 {children}
299 </div>
300);
301
302export const Container: FC<
303 PropsWithChildren<{ maxWidth?: number; class?: string }>
304> = ({ children, maxWidth = 800, class: cls }) => (
305 <div class={cls || ""} style={`max-width:${maxWidth}px`}>
306 {children}
307 </div>
308);
309
310// ─── Data Display Components ────────────────────────────────────────────────
311
312export const StatGroup: FC<{
313 stats: Array<{ label: string; value: string | number; color?: string }>;
314}> = ({ stats }) => (
315 <Flex gap={24} wrap>
316 {stats.map((stat) => (
317 <div>
318 <div style={`font-size:24px;font-weight:700;${stat.color ? `color:${stat.color};` : ""}`}>
319 {stat.value}
320 </div>
321 <Text size={13} muted>{stat.label}</Text>
322 </div>
323 ))}
324 </Flex>
325);
326
327export const KeyValue: FC<{ label: string; value: string | number }> = ({
328 label,
329 value,
330}) => (
331 <Flex justify="space-between" align="center" style="padding:8px 0;border-bottom:1px solid var(--border)">
332 <Text size={14} muted>{label}</Text>
333 <Text size={14}>{String(value)}</Text>
334 </Flex>
335);
336
337export const DataTable: FC<{
338 headers: string[];
339 rows: Array<Array<string | any>>;
340 class?: string;
341}> = ({ headers, rows, class: cls }) => (
342 <table class={cls || "file-table"}>
343 <thead>
344 <tr>
345 {headers.map((h) => (
346 <th style="padding:8px 16px;text-align:left;font-size:13px;color:var(--text-muted);border-bottom:1px solid var(--border)">{h}</th>
347 ))}
348 </tr>
349 </thead>
350 <tbody>
351 {rows.map((row) => (
352 <tr>
353 {row.map((cell) => (
354 <td style="padding:8px 16px;font-size:14px">{cell}</td>
355 ))}
356 </tr>
357 ))}
358 </tbody>
359 </table>
360);
361
362// ─── List Components ────────────────────────────────────────────────────────
363
364export const ListItem: FC<
365 PropsWithChildren<{ style?: string }>
366> = ({ children, style }) => (
367 <div class="issue-item" style={style}>
368 {children}
369 </div>
370);
371
372export const List: FC<PropsWithChildren<{ class?: string }>> = ({ children, class: cls }) => (
373 <div class={cls || "issue-list"}>
374 {children}
375 </div>
376);
377
378// ─── Code Display ───────────────────────────────────────────────────────────
379
380export const CodeBlock: FC<{
381 code: string;
382 language?: string;
383 showLineNumbers?: boolean;
384}> = ({ code, showLineNumbers = true }) => {
385 const lines = code.split("\n");
386 if (lines[lines.length - 1] === "") lines.pop();
387 return (
388 <div class="blob-code">
389 <table>
390 <tbody>
391 {lines.map((line, i) => (
392 <tr>
393 {showLineNumbers && <td class="line-num">{i + 1}</td>}
394 <td class="line-content">{line}</td>
395 </tr>
396 ))}
397 </tbody>
398 </table>
399 </div>
400 );
401};
402
403export const InlineCode: FC<PropsWithChildren> = ({ children }) => (
404 <code style="font-size:12px;background:var(--bg-tertiary);padding:2px 6px;border-radius:3px;font-family:var(--font-mono)">
405 {children}
406 </code>
407);
408
409export const CopyBlock: FC<{
410 text: string;
411 label?: string;
412}> = ({ text, label }) => (
413 <Flex gap={8} align="center" class="copy-block">
414 {label && <Text size={13} muted>{label}</Text>}
415 <code
416 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"
417 data-copy={text}
418 >
419 {text}
420 </code>
421 <button
422 type="button"
423 class="btn btn-sm copy-btn"
424 data-clipboard={text}
425 title="Copy to clipboard"
426 >
427 Copy
428 </button>
429 </Flex>
430);
431
432// ─── Notification Components ────────────────────────────────────────────────
433
434export const NotificationBell: FC<{ count: number; href: string }> = ({
435 count,
436 href,
437}) => (
438 <a href={href} class="notification-bell" title="Notifications">
439 <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
440 <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" />
441 </svg>
442 {count > 0 && <span class="notification-count">{count > 99 ? "99+" : count}</span>}
443 </a>
444);
445
446// ─── Profile Components ─────────────────────────────────────────────────────
447
448export const Avatar: FC<{
449 name: string;
450 url?: string;
451 size?: number;
452}> = ({ name, url, size = 40 }) => {
453 if (url) {
454 return (
455 <img
456 src={url}
457 alt={name}
458 style={`width:${size}px;height:${size}px;border-radius:50%;object-fit:cover`}
459 loading="lazy"
460 />
461 );
462 }
463 return (
464 <div
465 class="user-avatar"
466 style={`width:${size}px;height:${size}px;font-size:${size * 0.4}px`}
467 >
468 {name[0].toUpperCase()}
469 </div>
470 );
471};
472
473export const UserCard: FC<{
474 username: string;
475 displayName?: string | null;
476 bio?: string | null;
477 avatarUrl?: string | null;
478}> = ({ username, displayName, bio, avatarUrl }) => (
479 <div class="user-profile">
480 <Avatar name={displayName || username} url={avatarUrl || undefined} size={96} />
481 <div class="user-info">
482 <h2>{displayName || username}</h2>
483 <div class="username">@{username}</div>
484 {bio && <div class="bio">{bio}</div>}
485 </div>
486 </div>
487);
488
489// ─── Onboarding Components ──────────────────────────────────────────────────
490
491export const StepIndicator: FC<{
492 steps: Array<{ label: string; completed: boolean; active: boolean }>;
493}> = ({ steps }) => (
494 <Flex gap={0} align="center" class="step-indicator">
495 {steps.map((step, i) => (
496 <>
497 {i > 0 && <div class="step-line" data-completed={step.completed || steps[i - 1]?.completed ? "true" : "false"} />}
498 <Flex direction="column" align="center" gap={4}>
499 <div
500 class={`step-circle${step.completed ? " step-completed" : ""}${step.active ? " step-active" : ""}`}
501 >
502 {step.completed ? "\u2713" : i + 1}
503 </div>
504 <Text size={12} muted={!step.active}>{step.label}</Text>
505 </Flex>
506 </>
507 ))}
508 </Flex>
509);
510
511export const WelcomeHero: FC<
512 PropsWithChildren<{ title: string; subtitle?: string }>
513> = ({ children, title, subtitle }) => (
514 <div class="welcome-hero">
515 <h1>{title}</h1>
516 {subtitle && <p class="hero-subtitle">{subtitle}</p>}
517 {children}
518 </div>
519);
520
521export const FeatureCard: FC<{
522 icon: string;
523 title: string;
524 description: string;
525 href?: string;
526}> = ({ icon, title, description, href }) => {
527 const content = (
528 <Card class="feature-card">
529 <div class="feature-icon">{icon}</div>
530 <h3>{title}</h3>
531 <Text size={13} muted>{description}</Text>
532 </Card>
533 );
534 return href ? <a href={href} style="text-decoration:none">{content}</a> : content;
535};
536
537// ─── Search Components ──────────────────────────────────────────────────────
538
539export const SearchBar: FC<{
540 action: string;
541 value?: string;
542 placeholder?: string;
543 name?: string;
544}> = ({ action, value, placeholder = "Search...", name = "q" }) => (
545 <form method="get" action={action} style="margin-bottom:20px">
546 <Flex gap={8}>
547 <input
548 type="text"
549 name={name}
550 value={value}
551 placeholder={placeholder}
552 class="search-input"
553 autocomplete="off"
554 />
555 <Button type="submit" variant="primary">Search</Button>
556 </Flex>
557 </form>
558);
559
560export const SearchResults: FC<{
561 query: string;
562 count: number;
563}> = ({ query, count }) => (
564 <p style="font-size:14px;color:var(--text-muted);margin-bottom:16px">
565 {count} result{count !== 1 ? "s" : ""} for{" "}
566 <strong style="color:var(--text)">"{query}"</strong>
567 </p>
568);
569
570// ─── Markdown Content ───────────────────────────────────────────────────────
571
572export const MarkdownContent: FC<{ html: string }> = ({ html: htmlContent }) => (
573 <div class="markdown-body">
574 {html([htmlContent] as unknown as TemplateStringsArray)}
575 </div>
576);
577
578// ─── Comment Components ─────────────────────────────────────────────────────
579
580export const CommentBox: FC<{
581 author: string;
582 date: string | Date;
583 body: string;
584 isAi?: boolean;
585}> = ({ author, date, body, isAi }) => {
586 const dateStr = typeof date === "string" ? date : date.toISOString();
587 return (
588 <div class={`issue-comment-box${isAi ? " ai-review" : ""}`}>
589 <div class="comment-header">
590 <Flex gap={8} align="center">
591 <strong>{author}</strong>
592 {isAi && <Badge variant="default" style="font-size:11px">AI Review</Badge>}
593 <Text size={13} muted>commented {formatRelative(dateStr)}</Text>
594 </Flex>
595 </div>
596 <MarkdownContent html={body} />
597 </div>
598 );
599};
600
601export const CommentForm: FC<{
602 action: string;
603 csrfToken?: string;
604 placeholder?: string;
605 submitLabel?: string;
606 extraActions?: any;
607}> = ({ action, csrfToken, placeholder = "Leave a comment... (Markdown supported)", submitLabel = "Comment", extraActions }) => (
608 <div style="margin-top:20px">
609 <Form action={action} csrfToken={csrfToken}>
610 <FormGroup>
611 <div class="comment-editor">
612 <div class="editor-tabs">
613 <button type="button" class="editor-tab active" data-tab="write">Write</button>
614 <button type="button" class="editor-tab" data-tab="preview">Preview</button>
615 </div>
616 <TextArea
617 name="body"
618 rows={6}
619 required
620 placeholder={placeholder}
621 mono
622 />
623 <div class="editor-preview" style="display:none" />
624 </div>
625 </FormGroup>
626 <Flex gap={8}>
627 <Button type="submit" variant="primary">{submitLabel}</Button>
628 {extraActions}
629 </Flex>
630 </Form>
631 </div>
632);
633
634// ─── Tooltip Component ──────────────────────────────────────────────────────
635
636export const Tooltip: FC<PropsWithChildren<{ text: string }>> = ({
637 children,
638 text,
639}) => (
640 <span class="tooltip-wrapper" data-tooltip={text}>
641 {children}
642 </span>
643);
644
645// ─── Loading & Progress ─────────────────────────────────────────────────────
646
647export const Spinner: FC<{ size?: number }> = ({ size = 20 }) => (
648 <div
649 class="spinner"
650 style={`width:${size}px;height:${size}px`}
651 />
652);
653
654export const ProgressBar: FC<{ value: number; max?: number; color?: string }> = ({
655 value,
656 max = 100,
657 color,
658}) => (
659 <div class="progress-bar">
660 <div
661 class="progress-fill"
662 style={`width:${(value / max) * 100}%;${color ? `background:${color};` : ""}`}
663 />
664 </div>
665);
666
667// ─── Keyboard Shortcut Hint ─────────────────────────────────────────────────
668
669export const Kbd: FC<PropsWithChildren> = ({ children }) => (
670 <kbd class="kbd">{children}</kbd>
671);
672
673// ─── Utility Functions ──────────────────────────────────────────────────────
674
675export function formatRelative(dateStr: string | Date): string {
676 const date = typeof dateStr === "string" ? new Date(dateStr) : dateStr;
677 const now = new Date();
678 const diffMs = now.getTime() - date.getTime();
679 const diffMins = Math.floor(diffMs / 60000);
680 if (diffMins < 1) return "just now";
681 if (diffMins < 60) return `${diffMins}m ago`;
682 const diffHours = Math.floor(diffMins / 60);
683 if (diffHours < 24) return `${diffHours}h ago`;
684 const diffDays = Math.floor(diffHours / 24);
685 if (diffDays < 30) return `${diffDays}d ago`;
686 return date.toLocaleDateString("en-US", {
687 month: "short",
688 day: "numeric",
689 year: "numeric",
690 });
691}
692
693export function formatSize(bytes: number): string {
694 if (bytes < 1024) return `${bytes} B`;
695 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
696 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
697}