1import type { ReactNode } from "react";
2
3import { cn } from "~/lib/utils";
4import { Text } from "./ui/text";
5
6/** Fades a stack of ghost rows out toward the title. Shared by every page's ghost. */
7export const PAGE_EMPTY_GHOST_OPACITY = ["opacity-70", "opacity-45", "opacity-25"];
8
9/**
10 * Page empties accept an icon, faded ghostRows, or a custom ghost. Use fill to center content
11 * in the remaining scroll area when the page has no centered container.
12 */
13export function PageEmptyState({
14 fill = false,
15 align = "center",
16 icon,
17 ghostRows = 0,
18 ghost,
19 title,
20 description,
21 children,
22}: {
23 fill?: boolean;
24 align?: "center" | "start";
25 icon?: ReactNode;
26 ghostRows?: number;
27 ghost?: ReactNode;
28 title: ReactNode;
29 description: ReactNode;
30 children?: ReactNode;
31}) {
32 return (
33 <div
34 className={cn(
35 "mx-auto flex w-full max-w-150 flex-col items-center gap-7 pt-8 text-center",
36 fill && "flex-1 justify-center pb-16",
37 align === "start" && "max-w-105 items-start text-left",
38 )}
39 >
40 {ghost !== undefined ? (
41 <div aria-hidden className="w-full max-w-105">
42 {ghost}
43 </div>
44 ) : ghostRows > 0 ? (
45 <div aria-hidden className="flex w-full max-w-105 flex-col gap-2">
46 {Array.from({ length: ghostRows }, (_, index) => (
47 <div
48 key={index}
49 className={cn(
50 "flex items-center gap-3 rounded-card surface-card-settings px-3.5 py-3",
51 PAGE_EMPTY_GHOST_OPACITY[index] ?? "opacity-40",
52 )}
53 >
54 <span className="size-4 shrink-0 rounded-badge surface-raised" />
55 <span className="flex min-w-0 flex-1 flex-col gap-1.5">
56 <span className="h-1.5 w-3/5 rounded-full surface-raised" />
57 <span className="h-1.5 w-1/4 rounded-full surface-raised" />
58 </span>
59 <span className="h-5.5 w-16 shrink-0 rounded-control surface-raised" />
60 </div>
61 ))}
62 </div>
63 ) : icon !== undefined ? (
64 <span
65 aria-hidden
66 className="inline-flex size-12 items-center justify-center rounded-card surface-raised text-foreground [&_svg:not([class*='size-'])]:size-4.5"
67 >
68 {icon}
69 </span>
70 ) : null}
71 <div className="flex max-w-120 flex-col gap-1.5">
72 <Text variant="label" emphasis render={<h2 />}>
73 {title}
74 </Text>
75 <p className="text-body text-balance text-muted-foreground">{description}</p>
76 </div>
77 {children}
78 </div>
79 );
80}
81
82/** A row of equal hint tiles under an empty state, optionally under a small heading. */
83export function PageEmptyTiles({
84 title,
85 narrow = false,
86 children,
87}: {
88 title?: string;
89 /** Match the 420px text column instead of the full empty state. */
90 narrow?: boolean;
91 children: ReactNode;
92}) {
93 return (
94 <div className={cn("flex w-full flex-col gap-2 text-left", narrow && "max-w-105")}>
95 {/* Settings section rhythm: the title starts on the tile edge in a 28px row, 8px above
96 the tiles, which sit 12px apart like the other settings tile grids. */}
97 {title ? (
98 <h3 className="flex min-h-7 items-center text-label text-dim-foreground">{title}</h3>
99 ) : null}
100 <div className="grid gap-3 sm:auto-cols-fr sm:grid-flow-col">{children}</div>
101 </div>
102 );
103}
104
105/** `aside` sits at the tile's top right; children pin to its bottom. */
106export function PageEmptyTile({
107 icon,
108 title,
109 description,
110 aside,
111 children,
112}: {
113 icon: ReactNode;
114 title: ReactNode;
115 description: ReactNode;
116 aside?: ReactNode;
117 children?: ReactNode;
118}) {
119 return (
120 // The transparent border matches the settings cards and tiles, so text sits 13px in.
121 <div className="flex min-w-0 flex-col gap-1 rounded-card border border-transparent surface-card-settings p-3">
122 {aside ? (
123 <span className="mb-1 flex items-center justify-between gap-2 text-muted-foreground [&>svg]:size-3.5 [&>svg]:shrink-0">
124 {icon}
125 {aside}
126 </span>
127 ) : null}
128 <span className="flex items-center gap-2 text-body text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0 [&_svg]:text-muted-foreground">
129 {aside ? null : icon}
130 {title}
131 </span>
132 <span className="text-meta text-muted-foreground">{description}</span>
133 {children ? <div className="mt-auto">{children}</div> : null}
134 </div>
135 );
136}