Detailed changes
@@ -0,0 +1,123 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { formatDistanceToNow } from "date-fns";
+
+import { Status } from "@/__generated__/graphql";
+import { withRouter } from "@/../.storybook/decorators";
+
+import * as IssueRow from "./IssueRow";
+import { LabelBadge } from "./LabelBadge";
+
+const meta = {
+ component: IssueRow.Root,
+ decorators: [withRouter],
+} satisfies Meta<typeof IssueRow.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+const ago = formatDistanceToNow(new Date(Date.now() - 3600 * 1000), { addSuffix: true });
+
+export const OpenIssue: Story = {
+ args: { children: null },
+ render: () => (
+ <IssueRow.Root className="hover:bg-muted/30">
+ <IssueRow.StatusIcon status={Status.Open} />
+ <div className="min-w-0 flex-1">
+ <IssueRow.TitleArea>
+ <a href="#" className="text-foreground hover:text-primary font-medium hover:underline">
+ Fix login page crash on empty email
+ </a>
+ <LabelBadge name="bug" color={{ R: 252, G: 41, B: 41 }} />
+ <LabelBadge name="priority" color={{ R: 255, G: 152, B: 0 }} />
+ </IssueRow.TitleArea>
+ <IssueRow.Meta>
+ #a1b2c3 opened {ago} by <a href="#" className="hover:underline">Jane Doe</a>
+ </IssueRow.Meta>
+ </div>
+ <IssueRow.CommentCount count={3} />
+ </IssueRow.Root>
+ ),
+};
+
+export const ClosedIssue: Story = {
+ args: { children: null },
+ render: () => (
+ <IssueRow.Root>
+ <IssueRow.StatusIcon status={Status.Closed} />
+ <div className="min-w-0 flex-1">
+ <IssueRow.TitleArea>
+ <a href="#" className="text-foreground hover:text-primary font-medium hover:underline">
+ Add dark mode support
+ </a>
+ <LabelBadge name="enhancement" color={{ R: 163, G: 230, B: 53 }} />
+ </IssueRow.TitleArea>
+ <IssueRow.Meta>#d4e5f6 opened {ago}</IssueRow.Meta>
+ </div>
+ <IssueRow.CommentCount count={12} />
+ </IssueRow.Root>
+ ),
+};
+
+export const NoLabelsNoComments: Story = {
+ args: { children: null },
+ render: () => (
+ <IssueRow.Root>
+ <IssueRow.StatusIcon status={Status.Open} />
+ <div className="min-w-0 flex-1">
+ <IssueRow.TitleArea>
+ <a href="#" className="text-foreground hover:text-primary font-medium hover:underline">
+ Simple issue with no labels
+ </a>
+ </IssueRow.TitleArea>
+ <IssueRow.Meta>#abc123 opened {ago} by <a href="#" className="hover:underline">Bob</a></IssueRow.Meta>
+ </div>
+ <IssueRow.CommentCount count={0} />
+ </IssueRow.Root>
+ ),
+};
+
+export const List: Story = {
+ args: { children: null },
+ render: () => (
+ <div className="border-border rounded-md border">
+ <IssueRow.Root className="hover:bg-muted/30">
+ <IssueRow.StatusIcon status={Status.Open} />
+ <div className="min-w-0 flex-1">
+ <IssueRow.TitleArea>
+ <a href="#" className="text-foreground hover:text-primary font-medium hover:underline">
+ Fix login page crash on empty email
+ </a>
+ <LabelBadge name="bug" color={{ R: 252, G: 41, B: 41 }} />
+ </IssueRow.TitleArea>
+ <IssueRow.Meta>#a1b2c3 opened {ago} by Jane Doe</IssueRow.Meta>
+ </div>
+ <IssueRow.CommentCount count={3} />
+ </IssueRow.Root>
+ <IssueRow.Root className="hover:bg-muted/30">
+ <IssueRow.StatusIcon status={Status.Open} />
+ <div className="min-w-0 flex-1">
+ <IssueRow.TitleArea>
+ <a href="#" className="text-foreground hover:text-primary font-medium hover:underline">
+ Add dark mode support
+ </a>
+ <LabelBadge name="enhancement" color={{ R: 163, G: 230, B: 53 }} />
+ </IssueRow.TitleArea>
+ <IssueRow.Meta>#d4e5f6 opened {ago} by Bob</IssueRow.Meta>
+ </div>
+ <IssueRow.CommentCount count={0} />
+ </IssueRow.Root>
+ <IssueRow.Root className="hover:bg-muted/30">
+ <IssueRow.StatusIcon status={Status.Closed} />
+ <div className="min-w-0 flex-1">
+ <IssueRow.TitleArea>
+ <a href="#" className="text-foreground hover:text-primary font-medium hover:underline">
+ Update dependencies
+ </a>
+ </IssueRow.TitleArea>
+ <IssueRow.Meta>#g7h8i9 opened {ago} by Alice</IssueRow.Meta>
+ </div>
+ <IssueRow.CommentCount count={7} />
+ </IssueRow.Root>
+ </div>
+ ),
+};
@@ -0,0 +1,71 @@
+import { CircleDot, CircleCheck, MessageSquare } from "lucide-react";
+
+import { Status } from "@/__generated__/graphql";
+import { cn } from "@/lib/utils";
+
+interface RootProps {
+ className?: string;
+ children: React.ReactNode;
+}
+
+export function Root({ className, children }: RootProps) {
+ return (
+ <div
+ className={cn(
+ "border-border flex items-start gap-3 border-b px-4 py-3 last:border-0",
+ className,
+ )}
+ >
+ {children}
+ </div>
+ );
+}
+
+interface StatusIconProps {
+ status: Status;
+}
+
+export function StatusIcon({ status }: StatusIconProps) {
+ const isOpen = status === Status.Open;
+ const Icon = isOpen ? CircleDot : CircleCheck;
+ return (
+ <Icon
+ className={cn(
+ "mt-0.5 size-4 shrink-0",
+ isOpen
+ ? "text-green-600 dark:text-green-400"
+ : "text-purple-600 dark:text-purple-400",
+ )}
+ />
+ );
+}
+
+interface TitleAreaProps {
+ children: React.ReactNode;
+}
+
+export function TitleArea({ children }: TitleAreaProps) {
+ return <div className="flex flex-wrap items-baseline gap-2">{children}</div>;
+}
+
+interface MetaProps {
+ children: React.ReactNode;
+}
+
+export function Meta({ children }: MetaProps) {
+ return <p className="text-muted-foreground mt-0.5 text-xs">{children}</p>;
+}
+
+interface CommentCountProps {
+ count: number;
+}
+
+export function CommentCount({ count }: CommentCountProps) {
+ if (count <= 0) return null;
+ return (
+ <div className="text-muted-foreground flex shrink-0 items-center gap-1 text-xs">
+ <MessageSquare className="size-3.5" />
+ {count}
+ </div>
+ );
+}
@@ -1,7 +1,10 @@
+import { createLink, type LinkComponent } from "@tanstack/react-router";
+import * as React from "react";
+
interface LabelBadgeProps {
name: string;
color: { R: number; G: number; B: number };
- onClick?: (name: string) => void;
+ className?: string;
}
function contrastColor(r: number, g: number, b: number): string {
@@ -10,35 +13,50 @@ function contrastColor(r: number, g: number, b: number): string {
return luminance > 0.55 ? "rgba(0,0,0,0.75)" : "rgba(255,255,255,0.9)";
}
-// Coloured label pill. Renders as a <button> when onClick is provided,
-// used in BugRow and UserProfilePage to filter issues by label.
-export function LabelBadge({ name, color, onClick }: LabelBadgeProps) {
- const bg = `rgb(${color.R},${color.G},${color.B})`;
- const text = contrastColor(color.R, color.G, color.B);
+// Coloured label pill. Always renders as a <span>.
+// Use LabelBadgeLink for a clickable variant that navigates.
+const LabelBadge = React.forwardRef<HTMLSpanElement, LabelBadgeProps & Omit<React.HTMLAttributes<HTMLSpanElement>, "color">>(
+ ({ name, color, className, ...props }, ref) => {
+ const bg = `rgb(${color.R},${color.G},${color.B})`;
+ const text = contrastColor(color.R, color.G, color.B);
- if (onClick) {
return (
- <button
- type="button"
- className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium hover:opacity-80"
+ <span
+ ref={ref}
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${className ?? ""}`}
style={{ backgroundColor: bg, color: text }}
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- onClick(name);
- }}
+ {...props}
>
{name}
- </button>
+ </span>
);
- }
-
- return (
- <span
- className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
- style={{ backgroundColor: bg, color: text }}
- >
- {name}
- </span>
- );
-}
+ },
+);
+LabelBadge.displayName = "LabelBadge";
+
+// LabelBadge as a TanStack Router link — renders as <a> with label styling.
+const CreatedLabelBadgeLink = createLink(
+ React.forwardRef<HTMLAnchorElement, LabelBadgeProps & Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "color">>(
+ ({ name, color, className, ...props }, ref) => {
+ const bg = `rgb(${color.R},${color.G},${color.B})`;
+ const text = contrastColor(color.R, color.G, color.B);
+
+ return (
+ <a
+ ref={ref}
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium hover:opacity-80 ${className ?? ""}`}
+ style={{ backgroundColor: bg, color: text }}
+ {...props}
+ >
+ {name}
+ </a>
+ );
+ },
+ ),
+);
+
+const LabelBadgeLink: LinkComponent<typeof CreatedLabelBadgeLink> = (props) => {
+ return <CreatedLabelBadgeLink preload="intent" {...props} />;
+};
+
+export { LabelBadge, LabelBadgeLink };
@@ -0,0 +1,28 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import * as StatusTabs from "./status-tabs";
+
+const meta = {
+ component: StatusTabs.Root,
+} satisfies Meta<typeof StatusTabs.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+ args: { children: null },
+ render: () => (
+ <StatusTabs.Root>
+ <button className="bg-accent text-accent-foreground flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium">
+ <StatusTabs.OpenIndicator />
+ Open
+ <StatusTabs.Count>12</StatusTabs.Count>
+ </button>
+ <button className="text-muted-foreground hover:bg-accent/50 hover:text-foreground flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium">
+ <StatusTabs.ClosedIndicator />
+ Closed
+ <StatusTabs.Count>5</StatusTabs.Count>
+ </button>
+ </StatusTabs.Root>
+ ),
+};
@@ -0,0 +1,78 @@
+import { createLink, type LinkComponent } from "@tanstack/react-router";
+import { CircleDot, CircleCheck } from "lucide-react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+interface RootProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export function Root({ children, className }: RootProps) {
+ return <div className={cn("flex items-center gap-1", className)}>{children}</div>;
+}
+
+// Tab is a createLink-wrapped component — it IS the link.
+const TabComponent = React.forwardRef<
+ HTMLAnchorElement,
+ { className?: string; children?: React.ReactNode } & React.AnchorHTMLAttributes<HTMLAnchorElement>
+>(({ className, children, ...props }, ref) => {
+ return (
+ <a
+ ref={ref}
+ className={cn(
+ "text-muted-foreground hover:bg-accent/50 hover:text-foreground flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ </a>
+ );
+});
+TabComponent.displayName = "TabComponent";
+
+const CreatedTab = createLink(TabComponent);
+
+export const Tab: LinkComponent<typeof TabComponent> = (props) => {
+ return (
+ <CreatedTab
+ preload="intent"
+ activeProps={{ className: "bg-accent text-accent-foreground" }}
+ {...props}
+ />
+ );
+};
+
+interface IndicatorProps {
+ className?: string;
+}
+
+export function OpenIndicator({ className }: IndicatorProps) {
+ return (
+ <CircleDot
+ className={cn("size-4 group-[.active]:text-green-600 dark:group-[.active]:text-green-400", className)}
+ />
+ );
+}
+
+export function ClosedIndicator({ className }: IndicatorProps) {
+ return (
+ <CircleCheck
+ className={cn("size-4 group-[.active]:text-purple-600 dark:group-[.active]:text-purple-400", className)}
+ />
+ );
+}
+
+interface CountProps {
+ children: React.ReactNode;
+}
+
+export function Count({ children }: CountProps) {
+ return (
+ <span className="bg-muted ml-0.5 rounded-full px-1.5 py-0.5 text-xs leading-none">
+ {children}
+ </span>
+ );
+}
@@ -0,0 +1,82 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { useState } from "react";
+
+import { Markdown } from "@/components/content/Markdown";
+import { Textarea } from "@/components/ui/textarea";
+import { withRouter } from "@/../.storybook/decorators";
+
+import * as WritePreview from "./write-preview";
+
+const meta = {
+ component: WritePreview.Root,
+ decorators: [withRouter],
+} satisfies Meta<typeof WritePreview.Root>;
+
+export default meta;
+type Story = StoryObj<typeof meta>;
+
+export const Uncontrolled: Story = {
+ args: { children: null },
+ render: () => {
+ const [message, setMessage] = useState("Hello **world**!");
+ return (
+ <WritePreview.Root hasContent={!!message.trim()}>
+ <WritePreview.Tabs className="mb-2" />
+ <WritePreview.WriteSlot>
+ <Textarea
+ placeholder="Describe the issue…"
+ className="min-h-[200px]"
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ />
+ </WritePreview.WriteSlot>
+ <WritePreview.PreviewSlot>
+ <div className="border-input min-h-[200px] rounded-md border px-3 py-2">
+ <Markdown content={message} />
+ </div>
+ </WritePreview.PreviewSlot>
+ </WritePreview.Root>
+ );
+ },
+};
+
+export const Controlled: Story = {
+ args: { children: null },
+ render: () => {
+ const [message, setMessage] = useState("");
+ const [preview, setPreview] = useState(false);
+ return (
+ <WritePreview.Root hasContent={!!message.trim()} preview={preview} onPreviewChange={setPreview}>
+ <WritePreview.Tabs className="mb-2" />
+ <WritePreview.WriteSlot>
+ <Textarea
+ placeholder="Leave a comment…"
+ className="min-h-[120px]"
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ />
+ </WritePreview.WriteSlot>
+ <WritePreview.PreviewSlot>
+ <div className="min-h-[120px] px-4 py-3">
+ <Markdown content={message} />
+ </div>
+ </WritePreview.PreviewSlot>
+ </WritePreview.Root>
+ );
+ },
+};
+
+export const Empty: Story = {
+ args: { children: null },
+ render: () => (
+ <WritePreview.Root hasContent={false}>
+ <WritePreview.Tabs className="mb-2" />
+ <WritePreview.WriteSlot>
+ <Textarea placeholder="Preview is disabled until you type something…" className="min-h-[120px]" />
+ </WritePreview.WriteSlot>
+ <WritePreview.PreviewSlot>
+ <div className="min-h-[120px] px-4 py-3">Nothing to preview</div>
+ </WritePreview.PreviewSlot>
+ </WritePreview.Root>
+ ),
+};
@@ -0,0 +1,94 @@
+import { createContext, useContext, useState } from "react";
+
+import { cn } from "@/lib/utils";
+
+interface WritePreviewContextValue {
+ preview: boolean;
+ setPreview: (v: boolean) => void;
+ hasContent: boolean;
+}
+
+const WritePreviewContext = createContext<WritePreviewContextValue | null>(null);
+
+function useWritePreview() {
+ const ctx = useContext(WritePreviewContext);
+ if (!ctx) throw new Error("WritePreview sub-components must be used within WritePreview.Root");
+ return ctx;
+}
+
+interface RootProps {
+ children: React.ReactNode;
+ className?: string;
+ hasContent?: boolean;
+ /** Controlled mode: current preview state. */
+ preview?: boolean;
+ /** Controlled mode: callback when preview state changes. */
+ onPreviewChange?: (v: boolean) => void;
+}
+
+export function Root({ children, className, hasContent = false, preview, onPreviewChange }: RootProps) {
+ const [internalPreview, setInternalPreview] = useState(false);
+ const isControlled = preview !== undefined;
+
+ const value: WritePreviewContextValue = {
+ preview: isControlled ? preview : internalPreview,
+ setPreview: isControlled ? (onPreviewChange ?? (() => {})) : setInternalPreview,
+ hasContent,
+ };
+
+ return (
+ <WritePreviewContext value={value}>
+ <div className={className}>{children}</div>
+ </WritePreviewContext>
+ );
+}
+
+interface TabsProps {
+ className?: string;
+}
+
+export function Tabs({ className }: TabsProps) {
+ const { preview, setPreview, hasContent } = useWritePreview();
+
+ return (
+ <div className={cn("flex gap-2", className)}>
+ <button
+ type="button"
+ onClick={() => setPreview(false)}
+ className={cn(
+ "rounded-sm px-2 py-0.5 text-sm font-medium transition-colors",
+ !preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ Write
+ </button>
+ <button
+ type="button"
+ onClick={() => setPreview(true)}
+ disabled={!hasContent}
+ className={cn(
+ "rounded-sm px-2 py-0.5 text-sm font-medium transition-colors disabled:opacity-40",
+ preview ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+ Preview
+ </button>
+ </div>
+ );
+}
+
+interface SlotProps {
+ children: React.ReactNode;
+}
+
+export function WriteSlot({ children }: SlotProps) {
+ const { preview } = useWritePreview();
+ if (preview) return null;
+ return <>{children}</>;
+}
+
+export function PreviewSlot({ children }: SlotProps) {
+ const { preview } = useWritePreview();
+ if (!preview) return null;
+ return <>{children}</>;
+}