first commit

This commit is contained in:
2025-12-27 18:23:41 +03:00
commit 63a9425680
197 changed files with 7078 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
import React from "react";
import * as RadixAccordion from "@radix-ui/react-accordion";
import clsx from "clsx";
// import "./accordion.css";
export type AccordionItemData = {
value: string;
title: React.ReactNode;
content: React.ReactNode;
};
export function Accordion(props: {
items: AccordionItemData[];
type?: "single" | "multiple";
defaultValue?: string | string[];
collapsible?: boolean;
className?: string;
chevron?: React.ReactNode;
}) {
const { items, type = "single", defaultValue, collapsible = true, className, chevron } = props;
return (
<RadixAccordion.Root
className={clsx("ui-acc", className)}
type={type as any}
defaultValue={defaultValue as any}
collapsible={collapsible}
>
{items.map((it) => (
<RadixAccordion.Item key={it.value} className="ui-acc__item" value={it.value}>
<RadixAccordion.Header>
<RadixAccordion.Trigger className="ui-acc__trigger">
<span className="ui-acc__title">{it.title}</span>
<span className="ui-acc__chevron" aria-hidden>
{chevron ?? "▾"}
</span>
</RadixAccordion.Trigger>
</RadixAccordion.Header>
<RadixAccordion.Content className="ui-acc__content">{it.content}</RadixAccordion.Content>
</RadixAccordion.Item>
))}
</RadixAccordion.Root>
);
}

View File

@@ -0,0 +1,36 @@
.ui-acc{
border: 1px solid var(--ui-border);
background: var(--ui-panel);
border-radius: var(--ui-radius);
overflow: hidden;
}
.ui-acc__item{ border-top: 1px solid var(--ui-border); }
.ui-acc__item:first-child{ border-top: 0; }
.ui-acc__trigger{
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
background: transparent;
border: 0;
color: var(--ui-fg);
font: inherit;
cursor: pointer;
}
.ui-acc__trigger:hover{ background: rgba(255,255,255,0.04); }
:root[data-theme="light"] .ui-acc__trigger:hover{ background: rgba(17,19,24,0.04); }
.ui-acc__title{ font-weight: 800; }
.ui-acc__chevron{ color: var(--ui-muted); transition: transform .12s ease; }
.ui-acc__trigger[data-state="open"] .ui-acc__chevron{ transform: rotate(180deg); }
.ui-acc__content{
padding: 0 12px 12px 12px;
color: var(--ui-muted);
font-size: 13px;
}

View File

@@ -0,0 +1,29 @@
import React from "react";
import clsx from "clsx";
// import "./avatar.css";
export type AvatarSize = 20 | 24 | 32 | 40 | 48;
export type AvatarProps = {
size?: AvatarSize;
src?: string;
alt?: string;
initials?: string;
className?: string;
} & React.HTMLAttributes<HTMLSpanElement>;
export function Avatar({
size = 32,
src,
alt,
initials,
className,
...rest
}: AvatarProps) {
return (
<span className={clsx("ui-avatar", className)} data-size={String(size)} {...rest}>
{src ? <img src={src} alt={alt ?? ""} loading="lazy" /> : (initials ?? "?")}
</span>
);
}

View File

@@ -0,0 +1,26 @@
.ui-avatar{
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
overflow: hidden;
border: 1px solid var(--ui-border);
background: rgba(255,255,255,0.06);
color: var(--ui-fg);
font-weight: 800;
user-select: none;
}
:root[data-theme="light"] .ui-avatar{ background: rgba(17,19,24,0.06); }
.ui-avatar img{
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ui-avatar[data-size="20"]{ width: 20px; height: 20px; font-size: 10px; }
.ui-avatar[data-size="24"]{ width: 24px; height: 24px; font-size: 11px; }
.ui-avatar[data-size="32"]{ width: 32px; height: 32px; font-size: 12px; }
.ui-avatar[data-size="40"]{ width: 40px; height: 40px; font-size: 14px; }
.ui-avatar[data-size="48"]{ width: 48px; height: 48px; font-size: 16px; }

View File

@@ -0,0 +1,38 @@
import React from "react";
import clsx from "clsx";
// import "./badge.css";
export type BadgeTone = "neutral" | "primary" | "danger";
export type BadgeVariant = "solid" | "soft" | "outline";
export type BadgeSize = "sm" | "md";
export type BadgeProps = React.HTMLAttributes<HTMLSpanElement> & {
tone?: BadgeTone;
variant?: BadgeVariant;
size?: BadgeSize;
dot?: boolean;
};
export function Badge({
tone = "neutral",
variant = "soft",
size = "md",
dot,
className,
children,
...rest
}: BadgeProps) {
return (
<span
className={clsx("ui-badge", className)}
data-tone={tone}
data-variant={variant}
data-size={size}
{...rest}
>
{dot ? <span className="ui-badge__dot" aria-hidden /> : null}
{children}
</span>
);
}

View File

@@ -0,0 +1,58 @@
.ui-badge{
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 999px;
padding: 0 10px;
height: 22px;
font-size: 12px;
line-height: 1;
border: 1px solid transparent;
user-select: none;
white-space: nowrap;
}
.ui-badge[data-size="sm"]{ height: 18px; padding: 0 8px; font-size: 11px; }
.ui-badge[data-size="md"]{ height: 22px; }
/* variants */
.ui-badge[data-variant="solid"]{
background: var(--ui-fg);
color: var(--ui-bg);
}
.ui-badge[data-variant="soft"]{
background: rgba(255,255,255,0.08);
color: var(--ui-fg);
border-color: rgba(255,255,255,0.06);
}
:root[data-theme="light"] .ui-badge[data-variant="soft"]{
background: rgba(17,19,24,0.06);
border-color: rgba(17,19,24,0.08);
}
.ui-badge[data-variant="outline"]{
background: transparent;
color: var(--ui-fg);
border-color: var(--ui-border);
}
/* tones */
.ui-badge[data-tone="primary"][data-variant="solid"]{ background: var(--ui-primary); color: var(--ui-primary-fg); }
.ui-badge[data-tone="danger"][data-variant="solid"]{ background: var(--ui-danger); color: var(--ui-danger-fg); }
.ui-badge[data-tone="primary"][data-variant="soft"]{
background: color-mix(in oklab, var(--ui-primary) 20%, transparent);
border-color: color-mix(in oklab, var(--ui-primary) 30%, transparent);
}
.ui-badge[data-tone="danger"][data-variant="soft"]{
background: color-mix(in oklab, var(--ui-danger) 18%, transparent);
border-color: color-mix(in oklab, var(--ui-danger) 28%, transparent);
}
.ui-badge__dot{
width: 6px;
height: 6px;
border-radius: 999px;
background: currentColor;
opacity: 0.9;
}

View File

@@ -0,0 +1,27 @@
import React from "react";
import clsx from "clsx";
// import "./banner.css";
export type BannerTone = "neutral" | "primary" | "danger";
export function Banner(props: {
title: React.ReactNode;
description?: React.ReactNode;
icon?: React.ReactNode;
actions?: React.ReactNode;
tone?: BannerTone;
className?: string;
}) {
const { title, description, icon, actions, tone = "primary", className } = props;
return (
<div className={clsx("ui-banner", className)} data-tone={tone}>
{icon ? <div className="ui-banner__icon" aria-hidden>{icon}</div> : null}
<div className="ui-banner__body">
<h4 className="ui-banner__title">{title}</h4>
{description ? <p className="ui-banner__desc">{description}</p> : null}
{actions ? <div className="ui-banner__actions">{actions}</div> : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
.ui-banner{
background: var(--ui-panel);
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius);
box-shadow: var(--ui-shadow);
padding: 12px;
display: flex;
gap: 12px;
align-items: flex-start;
}
.ui-banner__icon{
width: 40px;
height: 40px;
border-radius: 12px;
background: color-mix(in oklab, var(--ui-primary) 20%, transparent);
display: inline-flex;
align-items: center;
justify-content: center;
}
.ui-banner__body{ flex: 1; display: grid; gap: 6px; }
.ui-banner__title{ margin: 0; font-weight: 800; font-size: 14px; }
.ui-banner__desc{ margin: 0; color: var(--ui-muted); font-size: 13px; }
.ui-banner__actions{ display: flex; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
.ui-banner[data-tone="danger"] .ui-banner__icon{
background: color-mix(in oklab, var(--ui-danger) 20%, transparent);
}

View File

@@ -0,0 +1,16 @@
import React from "react";
import clsx from "clsx";
import { Badge, type BadgeProps } from "../badge/Badge";
// import "./content-badge.css";
export type ContentBadgePosition = "tl" | "tr" | "bl" | "br";
export function ContentBadge(props: BadgeProps & { position?: ContentBadgePosition; className?: string }) {
const { position = "tl", className, ...rest } = props;
return (
<div className={clsx("ui-contentbadge", className)} data-pos={position}>
<Badge {...rest} />
</div>
);
}

View File

@@ -0,0 +1,9 @@
.ui-contentbadge{
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
}
.ui-contentbadge[data-pos="tr"]{ left: auto; right: 10px; }
.ui-contentbadge[data-pos="bl"]{ top: auto; bottom: 10px; }
.ui-contentbadge[data-pos="br"]{ top: auto; bottom: 10px; left: auto; right: 10px; }

View File

@@ -0,0 +1,40 @@
import React from "react";
import clsx from "clsx";
import { ImageBase } from "../image-base/ImageBase";
// import "./content-card.css";
export function ContentCard(props: {
title: React.ReactNode;
subtitle?: React.ReactNode;
meta?: React.ReactNode;
media?: { src: string; alt?: string; ratio?: number; overlay?: React.ReactNode };
href?: string;
onClick?: () => void;
className?: string;
}) {
const { title, subtitle, meta, media, href, onClick, className } = props;
const clickable = Boolean(href || onClick);
const Comp: any = href ? "a" : "div";
return (
<Comp
className={clsx("ui-contentcard", className)}
data-clickable={clickable ? "true" : "false"}
href={href}
onClick={onClick}
>
{media ? (
<div className="ui-contentcard__media">
<ImageBase src={media.src} alt={media.alt} ratio={media.ratio ?? (16 / 9)} overlay={media.overlay} />
</div>
) : null}
<div className="ui-contentcard__body">
<div className="ui-contentcard__title">{title}</div>
{subtitle ? <div className="ui-contentcard__subtitle">{subtitle}</div> : null}
{meta ? <div className="ui-contentcard__meta">{meta}</div> : null}
</div>
</Comp>
);
}

View File

@@ -0,0 +1,18 @@
.ui-contentcard{
border-radius: var(--ui-radius);
border: 1px solid var(--ui-border);
background: var(--ui-panel);
box-shadow: var(--ui-shadow);
overflow: hidden;
color: inherit;
text-decoration: none;
display: grid;
}
.ui-contentcard[data-clickable="true"]{ cursor: pointer; }
.ui-contentcard[data-clickable="true"]:active{ transform: translateY(1px); }
.ui-contentcard__media{ position: relative; }
.ui-contentcard__body{ padding: 12px; display: grid; gap: 6px; }
.ui-contentcard__title{ font-weight: 900; }
.ui-contentcard__subtitle{ color: var(--ui-muted); font-size: 13px; }
.ui-contentcard__meta{ color: var(--ui-muted); font-size: 12px; }

View File

@@ -0,0 +1,39 @@
import React from "react";
import clsx from "clsx";
// import "./counter.css";
export type CounterTone = "danger" | "primary" | "neutral";
export type CounterVariant = "solid" | "soft" | "outline";
export type CounterSize = "sm" | "md";
export type CounterProps = React.HTMLAttributes<HTMLSpanElement> & {
value: number;
max?: number; // e.g. 99 -> "99+"
tone?: CounterTone;
variant?: CounterVariant;
size?: CounterSize;
};
export function Counter({
value,
max = 99,
tone = "danger",
variant = "solid",
size = "md",
className,
...rest
}: CounterProps) {
const text = value > max ? `${max}+` : String(value);
return (
<span
className={clsx("ui-counter", className)}
data-tone={tone}
data-variant={variant}
data-size={size}
{...rest}
>
{text}
</span>
);
}

View File

@@ -0,0 +1,31 @@
.ui-counter{
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
font-size: 12px;
line-height: 1;
border: 1px solid transparent;
user-select: none;
white-space: nowrap;
}
.ui-counter[data-variant="solid"]{ background: var(--ui-danger); color: var(--ui-danger-fg); }
.ui-counter[data-variant="soft"]{
background: color-mix(in oklab, var(--ui-danger) 18%, transparent);
border-color: color-mix(in oklab, var(--ui-danger) 28%, transparent);
color: var(--ui-fg);
}
.ui-counter[data-variant="outline"]{ background: transparent; border-color: var(--ui-border); color: var(--ui-fg); }
.ui-counter[data-size="sm"]{ min-width: 18px; height: 18px; font-size: 11px; }
.ui-counter[data-size="md"]{ min-width: 20px; height: 20px; }
.ui-counter[data-tone="primary"][data-variant="solid"]{ background: var(--ui-primary); color: var(--ui-primary-fg); }
.ui-counter[data-tone="primary"][data-variant="soft"]{
background: color-mix(in oklab, var(--ui-primary) 18%, transparent);
border-color: color-mix(in oklab, var(--ui-primary) 28%, transparent);
}

View File

@@ -0,0 +1,31 @@
import React from "react";
import clsx from "clsx";
import { ImageBase } from "../image-base/ImageBase";
// import "./gallery.css";
export type GalleryItem = {
id: string;
src: string;
alt?: string;
};
export function Gallery(props: {
items: GalleryItem[];
cols?: 2 | 3 | 4;
ratio?: number;
onItemClick?: (id: string) => void;
className?: string;
}) {
const { items, cols = 3, ratio = 1, onItemClick, className } = props;
return (
<div className={clsx("ui-gallery", className)} data-cols={String(cols)}>
{items.map((it) => (
<div key={it.id} onClick={() => onItemClick?.(it.id)} style={{ cursor: onItemClick ? "pointer" : "default" }}>
<ImageBase src={it.src} alt={it.alt} ratio={ratio} />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,11 @@
.ui-gallery{
display: grid;
gap: 10px;
}
.ui-gallery[data-cols="2"]{ grid-template-columns: repeat(2, minmax(0,1fr)); }
.ui-gallery[data-cols="3"]{ grid-template-columns: repeat(3, minmax(0,1fr)); }
.ui-gallery[data-cols="4"]{ grid-template-columns: repeat(4, minmax(0,1fr)); }
@media (max-width: 560px){
.ui-gallery{ grid-template-columns: repeat(2, minmax(0,1fr)); }
}

View File

@@ -0,0 +1,35 @@
import React from "react";
import clsx from "clsx";
import { Avatar, type AvatarSize } from "../avatar/Avatar";
// import "./grid-avatar.css";
export type GridAvatarItem = {
id: string;
title: React.ReactNode;
subtitle?: React.ReactNode;
src?: string;
initials?: string;
};
export function GridAvatar(props: {
items: GridAvatarItem[];
cols?: 2 | 3 | 4;
size?: AvatarSize;
className?: string;
}) {
const { items, cols = 2, size = 32, className } = props;
return (
<div className={clsx("ui-gridavatar", className)} data-cols={String(cols)}>
{items.map((it) => (
<div key={it.id} className="ui-gridavatar__cell">
<Avatar size={size} src={it.src} initials={it.initials} />
<div className="ui-gridavatar__label">
<div className="ui-gridavatar__title">{it.title}</div>
{it.subtitle ? <div className="ui-gridavatar__subtitle">{it.subtitle}</div> : null}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,12 @@
.ui-gridavatar{
display: grid;
gap: 10px;
}
.ui-gridavatar[data-cols="2"]{ grid-template-columns: repeat(2, minmax(0,1fr)); }
.ui-gridavatar[data-cols="3"]{ grid-template-columns: repeat(3, minmax(0,1fr)); }
.ui-gridavatar[data-cols="4"]{ grid-template-columns: repeat(4, minmax(0,1fr)); }
.ui-gridavatar__cell{ display: flex; align-items: center; gap: 10px; }
.ui-gridavatar__label{ display: grid; gap: 2px; }
.ui-gridavatar__title{ font-weight: 700; }
.ui-gridavatar__subtitle{ font-size: 12px; color: var(--ui-muted); }

View File

@@ -0,0 +1,40 @@
import React from "react";
import clsx from "clsx";
import { ImageBase } from "../image-base/ImageBase";
// import "./horizontal-cell.css";
export function HorizontalCell(props: {
title: React.ReactNode;
subtitle?: React.ReactNode;
image?: { src: string; alt?: string; ratio?: number };
width?: number;
clickable?: boolean;
href?: string;
onClick?: () => void;
className?: string;
}) {
const { title, subtitle, image, width = 160, clickable, href, onClick, className } = props;
const Comp: any = href ? "a" : "div";
return (
<Comp
className={clsx("ui-hcell", className)}
data-clickable={clickable ? "true" : "false"}
href={href}
onClick={onClick}
style={{ ["--ui-hcell-w" as any]: `${width}px` } as React.CSSProperties}
>
{image ? (
<div className="ui-hcell__media">
<ImageBase src={image.src} alt={image.alt} ratio={image.ratio ?? 1} />
</div>
) : null}
<div className="ui-hcell__body">
<div className="ui-hcell__title">{title}</div>
{subtitle ? <div className="ui-hcell__subtitle">{subtitle}</div> : null}
</div>
</Comp>
);
}

View File

@@ -0,0 +1,19 @@
.ui-hcell{
width: var(--ui-hcell-w, 160px);
border-radius: var(--ui-radius);
border: 1px solid var(--ui-border);
background: var(--ui-panel);
box-shadow: var(--ui-shadow);
overflow: hidden;
text-decoration: none;
color: inherit;
display: grid;
}
.ui-hcell__media{ width: 100%; }
.ui-hcell__body{ padding: 10px 10px 12px 10px; display: grid; gap: 4px; }
.ui-hcell__title{ font-weight: 800; font-size: 13px; }
.ui-hcell__subtitle{ font-size: 12px; color: var(--ui-muted); }
.ui-hcell[data-clickable="true"]{ cursor: pointer; }
.ui-hcell[data-clickable="true"]:active{ transform: translateY(1px); }

View File

@@ -0,0 +1,13 @@
import React from "react";
import clsx from "clsx";
// import "./horizontal-scroll.css";
export function HorizontalScroll(props: React.PropsWithChildren<{ gap?: number; className?: string }>) {
const { gap = 12, className, children } = props;
return (
<div className={clsx("ui-hscroll", className)} style={{ ["--ui-hscroll-gap" as any]: `${gap}px` } as React.CSSProperties}>
<div className="ui-hscroll__inner">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
.ui-hscroll{
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
padding-bottom: 4px;
}
.ui-hscroll__inner{
display: flex;
gap: var(--ui-hscroll-gap, 12px);
width: max-content;
}
.ui-hscroll::-webkit-scrollbar{ height: 8px; }
.ui-hscroll::-webkit-scrollbar-thumb{ background: rgba(255,255,255,0.12); border-radius: 999px; }
:root[data-theme="light"] .ui-hscroll::-webkit-scrollbar-thumb{ background: rgba(17,19,24,0.12); }

View File

@@ -0,0 +1,36 @@
import React from "react";
import clsx from "clsx";
// import "./image-base.css";
export type ImageFit = "cover" | "contain";
export function ImageBase(props: {
src: string;
alt?: string;
ratio?: number; // width/height, e.g. 16/9
fit?: ImageFit;
overlay?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
const { src, alt, ratio = 16 / 9, fit = "cover", overlay, className, style } = props;
const paddingBottom = `${(1 / ratio) * 100}%`;
return (
<div
className={clsx("ui-imagebase", className)}
style={
{
...style,
["--ui-image-ratio" as any]: paddingBottom,
["--ui-image-fit" as any]: fit,
} as React.CSSProperties
}
>
<div className="ui-imagebase__ratio" />
<img src={src} alt={alt ?? ""} loading="lazy" />
{overlay ? <div className="ui-imagebase__overlay">{overlay}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,31 @@
.ui-imagebase{
position: relative;
width: 100%;
border-radius: var(--ui-radius);
overflow: hidden;
border: 1px solid var(--ui-border);
background: rgba(255,255,255,0.04);
}
:root[data-theme="light"] .ui-imagebase{ background: rgba(17,19,24,0.04); }
.ui-imagebase__ratio{
width: 100%;
height: 0;
padding-bottom: var(--ui-image-ratio, 56.25%); /* 16:9 default */
}
.ui-imagebase img,
.ui-imagebase video{
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: var(--ui-image-fit, cover);
display: block;
}
.ui-imagebase__overlay{
position: absolute;
inset: 0;
pointer-events: none;
}

View File

@@ -0,0 +1,14 @@
import React from "react";
import { ImageBase, type ImageFit } from "../image-base/ImageBase";
export function Image(props: {
src: string;
alt?: string;
ratio?: number;
fit?: ImageFit;
overlay?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
return <ImageBase {...props} />;
}

View File

@@ -0,0 +1,17 @@
export * from "./badge/Badge";
export * from "./counter/Counter";
export * from "./avatar/Avatar";
export * from "./users-stack/UsersStack";
export * from "./grid-avatar/GridAvatar";
export * from "./banner/Banner";
export * from "./placeholder/Placeholder";
export * from "./image-base/ImageBase";
export * from "./image/Image";
export * from "./horizontal-scroll/HorizontalScroll";
export * from "./horizontal-cell/HorizontalCell";
export * from "./info-row/InfoRow";
export * from "./mini-info-cell/MiniInfoCell";
export * from "./gallery/Gallery";
export * from "./content-badge/ContentBadge";
export * from "./content-card/ContentCard";
export * from "./accordion/Accordion";

View File

@@ -0,0 +1,18 @@
import React from "react";
import clsx from "clsx";
// import "./info-row.css";
export function InfoRow(props: {
label: React.ReactNode;
value: React.ReactNode;
className?: string;
}) {
const { label, value, className } = props;
return (
<div className={clsx("ui-inforow", className)}>
<div className="ui-inforow__label">{label}</div>
<div className="ui-inforow__value">{value}</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
.ui-inforow{
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.ui-inforow__label{ color: var(--ui-muted); font-size: 13px; }
.ui-inforow__value{ font-weight: 700; }

View File

@@ -0,0 +1,34 @@
import React from "react";
import clsx from "clsx";
// import "./mini-info-cell.css";
export function MiniInfoCell(props: {
title: React.ReactNode;
subtitle?: React.ReactNode;
before?: React.ReactNode;
after?: React.ReactNode;
href?: string;
onClick?: () => void;
className?: string;
}) {
const { title, subtitle, before, after, href, onClick, className } = props;
const clickable = Boolean(href || onClick);
const Comp: any = href ? "a" : "div";
return (
<Comp
className={clsx("ui-minicell", className)}
data-clickable={clickable ? "true" : "false"}
href={href}
onClick={onClick}
>
{before ? <div className="ui-minicell__before" aria-hidden>{before}</div> : null}
<div className="ui-minicell__body">
<div className="ui-minicell__title">{title}</div>
{subtitle ? <div className="ui-minicell__subtitle">{subtitle}</div> : null}
</div>
{after ? <div className="ui-minicell__after">{after}</div> : null}
</Comp>
);
}

View File

@@ -0,0 +1,17 @@
.ui-minicell{
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
text-decoration: none;
color: inherit;
}
.ui-minicell[data-clickable="true"]{ cursor: pointer; }
.ui-minicell[data-clickable="true"]:hover{ background: rgba(255,255,255,0.04); }
:root[data-theme="light"] .ui-minicell[data-clickable="true"]:hover{ background: rgba(17,19,24,0.04); }
.ui-minicell__before{ display: inline-flex; align-items: center; justify-content: center; }
.ui-minicell__body{ flex: 1; display: grid; gap: 2px; }
.ui-minicell__title{ font-weight: 700; }
.ui-minicell__subtitle{ font-size: 12px; color: var(--ui-muted); }
.ui-minicell__after{ display: inline-flex; align-items: center; justify-content: center; color: var(--ui-muted); }

View File

@@ -0,0 +1,22 @@
import React from "react";
import clsx from "clsx";
// import "./placeholder.css";
export function Placeholder(props: {
title: React.ReactNode;
description?: React.ReactNode;
icon?: React.ReactNode;
actions?: React.ReactNode;
className?: string;
}) {
const { title, description, icon, actions, className } = props;
return (
<div className={clsx("ui-placeholder", className)}>
{icon ? <div className="ui-placeholder__icon" aria-hidden>{icon}</div> : null}
<h3 className="ui-placeholder__title">{title}</h3>
{description ? <p className="ui-placeholder__desc">{description}</p> : null}
{actions ? <div className="ui-placeholder__actions">{actions}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,25 @@
.ui-placeholder{
background: var(--ui-panel);
border: 1px dashed color-mix(in oklab, var(--ui-border) 70%, transparent);
border-radius: var(--ui-radius);
padding: 18px;
text-align: center;
display: grid;
gap: 10px;
justify-items: center;
}
.ui-placeholder__icon{
width: 56px;
height: 56px;
border-radius: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.06);
}
:root[data-theme="light"] .ui-placeholder__icon{ background: rgba(17,19,24,0.06); }
.ui-placeholder__title{ margin: 0; font-weight: 900; }
.ui-placeholder__desc{ margin: 0; color: var(--ui-muted); }
.ui-placeholder__actions{ display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }

View File

@@ -0,0 +1,38 @@
import React from "react";
import clsx from "clsx";
// import "./rich-cell.css";
export function RichCell(props: {
title: React.ReactNode;
subtitle?: React.ReactNode;
bottom?: React.ReactNode;
before?: React.ReactNode;
after?: React.ReactNode;
href?: string;
onClick?: () => void;
className?: string;
}) {
const { title, subtitle, bottom, before, after, href, onClick, className } = props;
const clickable = Boolean(href || onClick);
const Comp: any = href ? "a" : "div";
return (
<Comp
className={clsx("ui-richcell", className)}
data-clickable={clickable ? "true" : "false"}
href={href}
onClick={onClick}
>
{before ? <div className="ui-richcell__before" aria-hidden>{before}</div> : null}
<div className="ui-richcell__body">
<div>
<div className="ui-richcell__title">{title}</div>
{subtitle ? <div className="ui-richcell__subtitle">{subtitle}</div> : null}
</div>
{bottom ? <div className="ui-richcell__bottom">{bottom}</div> : null}
</div>
{after ? <div className="ui-richcell__after">{after}</div> : null}
</Comp>
);
}

View File

@@ -0,0 +1,18 @@
.ui-richcell{
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
text-decoration: none;
color: inherit;
}
.ui-richcell[data-clickable="true"]{ cursor: pointer; }
.ui-richcell[data-clickable="true"]:hover{ background: rgba(255,255,255,0.04); }
:root[data-theme="light"] .ui-richcell[data-clickable="true"]:hover{ background: rgba(17,19,24,0.04); }
.ui-richcell__before{ display: inline-flex; }
.ui-richcell__body{ flex: 1; display: grid; gap: 6px; min-width: 0; }
.ui-richcell__title{ font-weight: 800; }
.ui-richcell__subtitle{ color: var(--ui-muted); font-size: 13px; }
.ui-richcell__bottom{ display: flex; gap: 8px; flex-wrap: wrap; }
.ui-richcell__after{ display: inline-flex; align-items: center; justify-content: center; color: var(--ui-muted); }

View File

@@ -0,0 +1,35 @@
import React from "react";
import clsx from "clsx";
import { Avatar, type AvatarSize } from "../avatar/Avatar";
// import "./users-stack.css";
export type UsersStackUser = {
id: string;
src?: string;
initials?: string;
alt?: string;
};
export type UsersStackProps = {
users: UsersStackUser[];
size?: AvatarSize;
maxVisible?: number;
className?: string;
};
export function UsersStack({ users, size = 32, maxVisible = 3, className }: UsersStackProps) {
const visible = users.slice(0, maxVisible);
const rest = Math.max(0, users.length - visible.length);
return (
<div className={clsx("ui-usersstack", className)}>
{visible.map((u) => (
<span key={u.id} className="ui-usersstack__item">
<Avatar size={size} src={u.src} alt={u.alt} initials={u.initials} />
</span>
))}
{rest > 0 ? <span className="ui-usersstack__more">+{rest}</span> : null}
</div>
);
}

View File

@@ -0,0 +1,24 @@
.ui-usersstack{
display: inline-flex;
align-items: center;
}
.ui-usersstack__item{
margin-left: -8px;
border-radius: 999px;
}
.ui-usersstack__item:first-child{ margin-left: 0; }
.ui-usersstack__more{
margin-left: -8px;
height: 32px;
min-width: 32px;
border-radius: 999px;
border: 1px solid var(--ui-border);
background: rgba(255,255,255,0.08);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: var(--ui-fg);
user-select: none;
}
:root[data-theme="light"] .ui-usersstack__more{ background: rgba(17,19,24,0.06); }