mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-06 02:25:24 +00:00
feat: shared tile look for cards and pages, plus new page pieces in SDK 1.1.0
This commit is contained in:
1 parent
1867be3f58
commit
19bdd589f3
41 files changed
+1497
-207
No files matched your search
@@ -1,18 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useInSeamlessTile } from "@/components/tile-grid/context";
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
// Inside an edge to edge tile layout the layout draws the lines.
|
||||
const tiled = useInSeamlessTile();
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-tile-card=""
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-none bg-card py-4 text-xs/relaxed text-card-foreground border border-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-none py-4 text-xs/relaxed text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-2 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-none *:[img:last-child]:rounded-none",
|
||||
!tiled && "border border-border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* A plain table in the tile look: sticky header on the page background, a
|
||||
* line under each row, no outer box. For wide data with real columns; use
|
||||
* DataView when the same list also has a card view.
|
||||
*/
|
||||
export function DataTable({
|
||||
className,
|
||||
containerClassName,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"table"> & {
|
||||
/** Classes for the scroll box around the table, like "flex-1". */
|
||||
containerClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 min-w-0 overflow-auto thin-scrollbar",
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
<table
|
||||
className={cn(
|
||||
"w-full border-collapse text-xs [&_td]:px-2 [&_td]:py-1.5 [&_th]:px-2 [&_th]:py-2 [&_th]:text-left [&_th]:text-[10px] [&_th]:font-semibold [&_th]:uppercase [&_th]:tracking-widest [&_th]:text-muted-foreground [&_thead]:sticky [&_thead]:top-0 [&_thead]:z-10 [&_thead]:bg-background [&_thead_tr]:border-b [&_tr]:border-b [&_tr]:border-border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A body row that highlights on hover, and when active. */
|
||||
export function DataTableRow({
|
||||
active = false,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<"tr"> & { active?: boolean }) {
|
||||
return (
|
||||
<tr
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
onClick && "cursor-pointer",
|
||||
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TileWall } from "@/components/tile-grid/TileWall";
|
||||
import { BottomEdge } from "@/components/tile-grid/edge";
|
||||
import {
|
||||
PANEL,
|
||||
gridSizeStyle,
|
||||
type GridSize,
|
||||
type PanelDensity,
|
||||
type PanelViewMode,
|
||||
@@ -91,7 +91,10 @@ function columnVisibility(hideBelow?: "md" | "lg"): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
/** One collection as a card grid or a dense row table. */
|
||||
/**
|
||||
* One collection as a card grid or a dense row table, edge to edge with
|
||||
* shared lines like the rest of the tile look.
|
||||
*/
|
||||
export function DataView<T>({
|
||||
items,
|
||||
view,
|
||||
@@ -132,21 +135,18 @@ export function DataView<T>({
|
||||
|
||||
if (view === "grid") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid",
|
||||
PANEL.gap,
|
||||
!size && gridClass(columns),
|
||||
className,
|
||||
)}
|
||||
style={size ? gridSizeStyle(size, minCardWidth) : undefined}
|
||||
<TileWall
|
||||
size={size}
|
||||
minWidth={minCardWidth ?? 320}
|
||||
gridClassName={size ? undefined : gridClass(columns)}
|
||||
className={className}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={getKey(item)} className="flex flex-col *:flex-1">
|
||||
{renderCard(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TileWall>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,68 +154,70 @@ export function DataView<T>({
|
||||
const rowHeight = density === "compact" ? "min-h-7" : "min-h-9";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("@container border border-border bg-card", className)}
|
||||
style={
|
||||
{
|
||||
"--data-view-cols": templates.base,
|
||||
"--data-view-cols-md": templates.md,
|
||||
"--data-view-cols-lg": templates.lg,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
role="table"
|
||||
>
|
||||
<BottomEdge>
|
||||
<div
|
||||
className={cn(
|
||||
LIST_GRID_CLASS,
|
||||
"sticky top-0 z-10 items-center border-b border-border bg-card text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
|
||||
)}
|
||||
className={cn("@container", className)}
|
||||
style={
|
||||
{
|
||||
"--data-view-cols": templates.base,
|
||||
"--data-view-cols-md": templates.md,
|
||||
"--data-view-cols-lg": templates.lg,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
role="table"
|
||||
>
|
||||
{listColumns.map((column) => (
|
||||
<span
|
||||
key={column.key}
|
||||
className={cn(
|
||||
"truncate py-2",
|
||||
column.align === "end" && "text-right",
|
||||
columnVisibility(column.hideBelow),
|
||||
)}
|
||||
>
|
||||
{column.header}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{items.map((item) => {
|
||||
const active = isRowActive?.(item) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={getKey(item)}
|
||||
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
||||
<div
|
||||
className={cn(
|
||||
LIST_GRID_CLASS,
|
||||
"sticky top-0 z-10 items-center border-b border-border bg-background text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{listColumns.map((column) => (
|
||||
<span
|
||||
key={column.key}
|
||||
className={cn(
|
||||
LIST_GRID_CLASS,
|
||||
"items-center border-b border-border/50 text-xs transition-colors last:border-0",
|
||||
rowHeight,
|
||||
onRowClick && "cursor-pointer",
|
||||
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
|
||||
"truncate py-2",
|
||||
column.align === "end" && "text-right",
|
||||
columnVisibility(column.hideBelow),
|
||||
)}
|
||||
>
|
||||
{listColumns.map((column) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
column.align === "end" && "text-right",
|
||||
columnVisibility(column.hideBelow),
|
||||
)}
|
||||
>
|
||||
{column.cell(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{column.header}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{items.map((item) => {
|
||||
const active = isRowActive?.(item) ?? false;
|
||||
return (
|
||||
<div
|
||||
key={getKey(item)}
|
||||
onClick={onRowClick ? () => onRowClick(item) : undefined}
|
||||
className={cn(
|
||||
LIST_GRID_CLASS,
|
||||
"items-center border-b border-border/50 text-xs transition-colors last:border-border",
|
||||
rowHeight,
|
||||
onRowClick && "cursor-pointer",
|
||||
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
{listColumns.map((column) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
column.align === "end" && "text-right",
|
||||
columnVisibility(column.hideBelow),
|
||||
)}
|
||||
>
|
||||
{column.cell(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BottomEdge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InfoItem {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
/** Monospace value, for ids, paths and addresses. */
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
/** Label and value pairs in two aligned columns. Empty values are skipped. */
|
||||
export function InfoGrid({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: InfoItem[];
|
||||
className?: string;
|
||||
}) {
|
||||
const shown = items.filter(
|
||||
(item) =>
|
||||
item.value !== null && item.value !== undefined && item.value !== "",
|
||||
);
|
||||
if (shown.length === 0) return null;
|
||||
return (
|
||||
<dl
|
||||
className={cn(
|
||||
"grid grid-cols-[max-content_minmax(0,1fr)] gap-x-4 gap-y-1 text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{shown.map((item, index) => (
|
||||
<div key={index} className="contents">
|
||||
<dt className="text-muted-foreground">{item.label}</dt>
|
||||
<dd className={cn("min-w-0 break-words", item.mono && "font-mono")}>
|
||||
{item.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A spinner with a short line, for a view waiting on its first load. */
|
||||
export function LoadingState({
|
||||
label,
|
||||
compact = false,
|
||||
className,
|
||||
}: {
|
||||
label?: string;
|
||||
/** One inline line instead of the centered block. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const text = label ?? t("common.loading");
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-5 animate-spin opacity-60" />
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,9 +35,10 @@ export function MetricCard({
|
||||
const tiled = useInSeamlessTile();
|
||||
return (
|
||||
<div
|
||||
data-tile-card=""
|
||||
className={cn(
|
||||
"flex min-h-0 flex-col overflow-hidden",
|
||||
!tiled && "border border-border bg-card",
|
||||
!tiled && "border border-border",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* A block that explains why a view has nothing to show yet and what to do,
|
||||
* like "Samba is not installed" with an Install button. Unlike EmptyState the
|
||||
* action always shows.
|
||||
*/
|
||||
export function NoticePanel({
|
||||
icon: Icon,
|
||||
title,
|
||||
children,
|
||||
action,
|
||||
tone = "default",
|
||||
className,
|
||||
}: {
|
||||
icon?: LucideIcon;
|
||||
title: ReactNode;
|
||||
children?: ReactNode;
|
||||
action?: ReactNode;
|
||||
tone?: "default" | "warning" | "destructive";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-2 px-4 py-4",
|
||||
tone === "warning" && "bg-warning/5",
|
||||
tone === "destructive" && "bg-destructive/5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn(
|
||||
"size-5 text-muted-foreground",
|
||||
tone === "warning" && "text-warning",
|
||||
tone === "destructive" && "text-destructive",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
{children && (
|
||||
<div className="text-xs leading-relaxed text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{action && <div className="mt-1 flex flex-wrap gap-2">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** One page of a list, with the page clamped to what exists. */
|
||||
export function pageOf<T>(
|
||||
items: T[],
|
||||
page: number,
|
||||
size: number,
|
||||
): { rows: T[]; page: number; pages: number } {
|
||||
const pages = Math.max(1, Math.ceil(items.length / size));
|
||||
const at = Math.min(Math.max(0, page), pages - 1);
|
||||
return { rows: items.slice(at * size, at * size + size), page: at, pages };
|
||||
}
|
||||
|
||||
/** Pages a list in memory. Goes back to the first page when `reset` changes. */
|
||||
export function usePaged<T>(items: T[], size: number, reset?: unknown) {
|
||||
const [page, setPage] = useState(0);
|
||||
useEffect(() => setPage(0), [reset]);
|
||||
return { ...pageOf(items, page, size), setPage };
|
||||
}
|
||||
|
||||
/** Previous and Next under a list. Hidden when there is only one page. */
|
||||
export function Pager({
|
||||
page,
|
||||
pages,
|
||||
hasNext,
|
||||
onPage,
|
||||
className,
|
||||
}: {
|
||||
page: number;
|
||||
/** Total pages when known; leave out for a list read a page at a time. */
|
||||
pages?: number;
|
||||
hasNext?: boolean;
|
||||
onPage: (next: number) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const next = hasNext ?? (pages !== undefined && page < pages - 1);
|
||||
if (page === 0 && !next) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-2 border-t border-border px-2 py-1 text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={page === 0}
|
||||
onClick={() => onPage(page - 1)}
|
||||
>
|
||||
{t("panel.pager.previous")}
|
||||
</Button>
|
||||
<span>
|
||||
{pages !== undefined
|
||||
? t("panel.pager.pageOf", { page: page + 1, pages })
|
||||
: t("panel.pager.page", { page: page + 1 })}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!next}
|
||||
onClick={() => onPage(page + 1)}
|
||||
>
|
||||
{t("panel.pager.next")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Columns3,
|
||||
Grid3X3,
|
||||
List,
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
Search,
|
||||
X,
|
||||
@@ -14,6 +15,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/separator";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
import { useInSeamlessTile } from "@/components/tile-grid/context";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -215,6 +217,59 @@ export function BackButton({
|
||||
}
|
||||
|
||||
/** Search box sized to match toolbar buttons. Esc clears it. */
|
||||
/** The refresh button panel headers show, spinning while it loads. */
|
||||
export function RefreshButton({
|
||||
onClick,
|
||||
loading = false,
|
||||
disabled,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const text = label ?? t("common.refresh");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled ?? loading}
|
||||
title={text}
|
||||
aria-label={text}
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center text-accent-brand transition-colors hover:bg-muted/40 disabled:opacity-60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={cn("size-4", loading && "animate-spin")} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The outer box of a plugin tab: fills the tab and clips, so the PanelShell
|
||||
* inside can scroll its own body.
|
||||
*/
|
||||
export function PanelFrame({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("h-full w-full overflow-hidden", className)}>
|
||||
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelSearch({
|
||||
value,
|
||||
onChange,
|
||||
@@ -513,13 +568,18 @@ export function GroupHeading({
|
||||
title,
|
||||
count,
|
||||
action,
|
||||
plain,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number;
|
||||
action?: React.ReactNode;
|
||||
/** No rule after the title. The default inside a tile layout. */
|
||||
plain?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const tiled = useInSeamlessTile();
|
||||
const noRule = plain ?? tiled;
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
@@ -530,7 +590,11 @@ export function GroupHeading({
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
{noRule ? (
|
||||
<div className="flex-1" />
|
||||
) : (
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context";
|
||||
import { useState } from "react";
|
||||
import { useInSeamlessTile } from "@/components/tile-grid/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type React from "react";
|
||||
|
||||
export function SectionCard({
|
||||
@@ -18,19 +20,28 @@ export function SectionCard({
|
||||
/** Lets settings search jump to this card. */
|
||||
rowId?: string;
|
||||
}) {
|
||||
// Inside an edge to edge tile layout the layout draws the lines.
|
||||
const tiled = useInSeamlessTile();
|
||||
return (
|
||||
<div
|
||||
data-tile-card=""
|
||||
data-setting-row={rowId}
|
||||
className={`flex flex-col border border-border bg-card overflow-hidden ${className ?? ""}`}
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden",
|
||||
!tiled && "border border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<span className="flex text-muted-foreground [&_svg]:size-3">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground flex-1">
|
||||
{title}
|
||||
</span>
|
||||
{action && <div className="ml-auto">{action}</div>}
|
||||
</div>
|
||||
<div className="px-3 md:px-4 py-1">{children}</div>
|
||||
<div className="px-3 py-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
export interface SideNavEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
/** A count or short badge after the label. */
|
||||
badge?: ReactNode;
|
||||
/** Opens it in a normal tab instead, for split view. */
|
||||
onPopOut?: () => void;
|
||||
popOutLabel?: string;
|
||||
/** Draws a line above this entry. */
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The left list of sections a page switches between, like the host page and
|
||||
* the Operations tab. On a phone it turns into a select over the content.
|
||||
*/
|
||||
export function SideNav({
|
||||
entries,
|
||||
active,
|
||||
onSelect,
|
||||
footer,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
entries: SideNavEntry[];
|
||||
/** Names the list for screen readers and the phone select. */
|
||||
label?: string;
|
||||
active: string;
|
||||
onSelect: (id: string) => void;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"hidden w-44 shrink-0 flex-col gap-0.5 overflow-auto border-r border-border p-2 thin-scrollbar md:flex",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className="contents">
|
||||
{entry.divider && <div className="my-1 h-px bg-border" />}
|
||||
<SideNavItem
|
||||
entry={entry}
|
||||
active={entry.id === active}
|
||||
onClick={() => onSelect(entry.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{footer && <div className="mt-auto pt-2">{footer}</div>}
|
||||
</nav>
|
||||
<div className="shrink-0 border-b border-border p-2 md:hidden">
|
||||
<Select2
|
||||
value={active}
|
||||
aria-label={label}
|
||||
onChange={(event) => onSelect(event.target.value)}
|
||||
className="h-8 w-full px-2.5 text-xs"
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideNavItem({
|
||||
entry,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
entry: SideNavEntry;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const Icon = entry.icon;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center",
|
||||
active
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-xs"
|
||||
>
|
||||
{Icon && <Icon className="size-3.5 shrink-0" />}
|
||||
<span className="truncate">{entry.label}</span>
|
||||
{entry.badge !== undefined && entry.badge !== null && (
|
||||
<span className="ml-auto shrink-0 px-1 text-[10px] tabular-nums">
|
||||
{entry.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{entry.onPopOut && (
|
||||
<button
|
||||
type="button"
|
||||
title={entry.popOutLabel}
|
||||
aria-label={entry.popOutLabel}
|
||||
onClick={entry.onPopOut}
|
||||
className={cn(
|
||||
"mr-1 shrink-0 p-1 text-muted-foreground hover:text-foreground",
|
||||
!active && "opacity-0 focus:opacity-100 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Meter } from "@/components/meter";
|
||||
import { TileFrame } from "@/components/tile-grid/TileFrame";
|
||||
|
||||
const VALUE_TONE = {
|
||||
default: "",
|
||||
ok: "text-green-500",
|
||||
warn: "text-warning",
|
||||
bad: "text-destructive",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* One number in the tile look: the small title row, a big value, then an
|
||||
* optional usage bar and a hint line. Put several in a TileWall for a strip.
|
||||
*/
|
||||
export function StatTile({
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
unit,
|
||||
percent,
|
||||
hint,
|
||||
tone = "default",
|
||||
actions,
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
icon?: ReactNode;
|
||||
value: ReactNode;
|
||||
unit?: ReactNode;
|
||||
/** Draws a usage bar under the value. */
|
||||
percent?: number | null;
|
||||
hint?: ReactNode;
|
||||
tone?: keyof typeof VALUE_TONE;
|
||||
actions?: ReactNode;
|
||||
onClick?: () => void;
|
||||
/** Extra lines under the hint. */
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const body = (
|
||||
<div className="flex flex-col gap-1.5 px-3 py-2.5">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-2xl font-bold tabular-nums leading-none",
|
||||
VALUE_TONE[tone],
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
{percent !== undefined && <Meter percent={percent} />}
|
||||
{hint && (
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{hint}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<TileFrame
|
||||
title={label}
|
||||
icon={icon}
|
||||
actions={actions}
|
||||
className={className}
|
||||
bodyClassName="overflow-hidden"
|
||||
>
|
||||
{onClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
) : (
|
||||
body
|
||||
)}
|
||||
</TileFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ListBadge } from "@/components/list-kit";
|
||||
|
||||
export type StatusTone = "ok" | "warn" | "bad" | "info" | "idle";
|
||||
|
||||
const DOT: Record<StatusTone, string> = {
|
||||
ok: "bg-green-500",
|
||||
warn: "bg-warning",
|
||||
bad: "bg-destructive",
|
||||
info: "bg-accent-brand",
|
||||
idle: "bg-muted-foreground/50",
|
||||
};
|
||||
|
||||
const BADGE = {
|
||||
ok: "success",
|
||||
warn: "warning",
|
||||
bad: "destructive",
|
||||
info: "brand",
|
||||
idle: "muted",
|
||||
} as const;
|
||||
|
||||
// Common state words, so most callers only pass the state.
|
||||
const WORDS: Record<string, StatusTone> = {
|
||||
running: "ok",
|
||||
up: "ok",
|
||||
online: "ok",
|
||||
active: "ok",
|
||||
healthy: "ok",
|
||||
ready: "ok",
|
||||
ok: "ok",
|
||||
success: "ok",
|
||||
succeeded: "ok",
|
||||
done: "ok",
|
||||
completed: "ok",
|
||||
enabled: "ok",
|
||||
connected: "ok",
|
||||
bound: "ok",
|
||||
compliant: "ok",
|
||||
pending: "warn",
|
||||
paused: "warn",
|
||||
restarting: "warn",
|
||||
starting: "warn",
|
||||
stopping: "warn",
|
||||
waiting: "warn",
|
||||
degraded: "warn",
|
||||
warn: "warn",
|
||||
warning: "warn",
|
||||
unhealthy: "warn",
|
||||
drifted: "warn",
|
||||
pending_approval: "warn",
|
||||
needs_interaction: "warn",
|
||||
interrupted: "warn",
|
||||
update: "info",
|
||||
created: "idle",
|
||||
exited: "idle",
|
||||
stopped: "idle",
|
||||
inactive: "idle",
|
||||
disabled: "idle",
|
||||
offline: "bad",
|
||||
down: "bad",
|
||||
dead: "bad",
|
||||
failed: "bad",
|
||||
fail: "bad",
|
||||
error: "bad",
|
||||
crashloopbackoff: "bad",
|
||||
};
|
||||
|
||||
/** The tone for a state word like "running" or "failed". Unknown words are idle. */
|
||||
export function statusTone(state: string | null | undefined): StatusTone {
|
||||
if (!state) return "idle";
|
||||
const key = state.toLowerCase().replace(/[\s-]+/g, "_");
|
||||
return WORDS[key] ?? WORDS[key.replace(/_/g, "")] ?? "idle";
|
||||
}
|
||||
|
||||
/** A small round status light. */
|
||||
export function StatusDot({
|
||||
tone,
|
||||
state,
|
||||
pulse = false,
|
||||
className,
|
||||
title,
|
||||
}: {
|
||||
tone?: StatusTone;
|
||||
/** A state word, used when no tone is given. */
|
||||
state?: string | null;
|
||||
pulse?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const resolved = tone ?? statusTone(state);
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
aria-hidden={title ? undefined : true}
|
||||
className={cn(
|
||||
"inline-block size-2 shrink-0 rounded-full",
|
||||
DOT[resolved],
|
||||
pulse && "animate-pulse",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A state as a small colored badge. The text is the state unless children are given. */
|
||||
export function StatusBadge({
|
||||
tone,
|
||||
state,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tone?: StatusTone;
|
||||
state?: string | null;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const resolved = tone ?? statusTone(state);
|
||||
return (
|
||||
<ListBadge tone={BADGE[resolved]} className={className}>
|
||||
{children ?? state}
|
||||
</ListBadge>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BackButton, PanelShell } from "@/components/panel-layout";
|
||||
import { TileColumn } from "@/components/tile-grid/TileColumn";
|
||||
import {
|
||||
PROMPT_BUTTON,
|
||||
PROMPT_DESTRUCTIVE_BUTTON,
|
||||
@@ -397,6 +398,7 @@ export function InlineView({
|
||||
onBeforeClose,
|
||||
scroll = true,
|
||||
bare = false,
|
||||
stacked = false,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
@@ -416,6 +418,11 @@ export function InlineView({
|
||||
scroll?: boolean;
|
||||
/** Draw children edge to edge, for views with their own bands. */
|
||||
bare?: boolean;
|
||||
/**
|
||||
* Cards stacked in one column with a line between them and no padding,
|
||||
* like a settings page. For forms built from SectionCards.
|
||||
*/
|
||||
stacked?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
@@ -462,6 +469,10 @@ export function InlineView({
|
||||
>
|
||||
{bare ? (
|
||||
children
|
||||
) : stacked ? (
|
||||
<TileColumn className={cn("w-full", className)}>
|
||||
{children}
|
||||
</TileColumn>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -96,7 +96,7 @@ export function TabStrip({
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center",
|
||||
variant === "secondary" && "border-t border-border bg-card",
|
||||
variant === "secondary" && "border-t border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { BottomEdge, EdgeOwned } from "./edge";
|
||||
|
||||
/**
|
||||
* A column of cards that can sit at any depth, like a settings page built
|
||||
* from several panels. Cards drop their box and the outermost ones draw a
|
||||
* line under themselves. Use TileStack when the sections are direct children.
|
||||
*/
|
||||
export function TileColumn({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={true}>
|
||||
<BottomEdge
|
||||
className={className}
|
||||
innerClassName="flex min-w-0 flex-col [&_[data-tile-card]]:border-x-0 [&_[data-tile-card]]:border-t-0 [&_[data-tile-card]]:border-b [&_[data-tile-card]]:border-border [&_[data-tile-card]_[data-tile-card]]:border-b-0"
|
||||
>
|
||||
<EdgeOwned>{children}</EdgeOwned>
|
||||
</BottomEdge>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The bar over a tile grid while it is being edited: a hint or extra controls
|
||||
* on the left, then Reset, Add tiles (while the picker is closed) and Done.
|
||||
*/
|
||||
export function TileEditBar({
|
||||
children,
|
||||
onReset,
|
||||
onAdd,
|
||||
onDone,
|
||||
className,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
onReset?: () => void;
|
||||
/** Leave out while the tile picker is open. */
|
||||
onAdd?: () => void;
|
||||
onDone: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-1.5 text-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children && (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{onReset && (
|
||||
<Button size="xs" variant="ghost" className="gap-1" onClick={onReset}>
|
||||
<RotateCcw className="size-3" />
|
||||
{t("tileGrid.reset")}
|
||||
</Button>
|
||||
)}
|
||||
{onAdd && (
|
||||
<Button size="xs" variant="ghost" onClick={onAdd}>
|
||||
{t("tileGrid.addTiles")}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="xs" onClick={onDone}>
|
||||
{t("tileGrid.done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "./layout";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { gridLines } from "./layout";
|
||||
import { scrollParent } from "./edge";
|
||||
|
||||
type Edge = "left" | "right" | "bottom" | "corner";
|
||||
interface Proposal {
|
||||
@@ -83,14 +84,6 @@ function propose(base: TileSlot[], id: string, proposal: Proposal) {
|
||||
return base.map((slot) => (slot.id === id ? { ...slot, ...proposal } : slot));
|
||||
}
|
||||
|
||||
function scrollParent(element: HTMLElement | null) {
|
||||
for (let node = element?.parentElement; node; node = node.parentElement) {
|
||||
const { overflowY } = getComputedStyle(node);
|
||||
if (overflowY === "auto" || overflowY === "scroll") return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const NO_DRAG =
|
||||
"button, a, input, select, textarea, label, [role=separator], [contenteditable=true], .tg-handle";
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Children, isValidElement, type ReactNode } from "react";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { BottomEdge, EdgeOwned } from "./edge";
|
||||
|
||||
/**
|
||||
* Sections stacked edge to edge with a line under each, the column form of
|
||||
* the tile look. Cards inside drop their own border. The last line goes away
|
||||
* when the stack reaches the bottom of the screen.
|
||||
*/
|
||||
export function TileStack({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={true}>
|
||||
<BottomEdge className={className} innerClassName="flex min-w-0 flex-col">
|
||||
<EdgeOwned>
|
||||
{Children.toArray(children).map((section, index) => (
|
||||
<div
|
||||
key={keyOf(section, index)}
|
||||
className="min-w-0 shrink-0 border-b border-border"
|
||||
>
|
||||
{section}
|
||||
</div>
|
||||
))}
|
||||
</EdgeOwned>
|
||||
</BottomEdge>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function keyOf(node: ReactNode, index: number): string | number {
|
||||
return isValidElement(node) && node.key !== null ? node.key : index;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Children, isValidElement, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { gridSizeStyle, type GridSize } from "@/components/panel-layout";
|
||||
import { SeamlessTileContext } from "./context";
|
||||
import { BottomEdge } from "./edge";
|
||||
|
||||
/**
|
||||
* A fixed grid of tiles edge to edge with one line between neighbours, for
|
||||
* stat strips and card lists. `size` is tiles per row ("auto" fits as many as
|
||||
* `minWidth` allows). Use TileGrid when people should move and resize tiles.
|
||||
*/
|
||||
export function TileWall({
|
||||
size = "auto",
|
||||
minWidth = 280,
|
||||
gridClassName,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
size?: GridSize;
|
||||
minWidth?: number;
|
||||
/** Column classes (like "grid-cols-1 md:grid-cols-3") in place of size. */
|
||||
gridClassName?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const tiles = Children.toArray(children);
|
||||
return (
|
||||
<SeamlessTileContext.Provider value={true}>
|
||||
{/* The grid sits a pixel wider so the right edge line is cut off. */}
|
||||
<BottomEdge className={className}>
|
||||
<div
|
||||
className={cn("-mr-px grid", gridClassName)}
|
||||
style={
|
||||
gridClassName ? undefined : gridSizeStyle(size, minWidth, "0px")
|
||||
}
|
||||
>
|
||||
{tiles.map((tile, index) => (
|
||||
<div
|
||||
key={keyOf(tile, index)}
|
||||
className="flex min-w-0 flex-col border-b border-r border-border *:flex-1"
|
||||
>
|
||||
{tile}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BottomEdge>
|
||||
</SeamlessTileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function keyOf(node: ReactNode, index: number): string | number {
|
||||
return isValidElement(node) && node.key !== null ? node.key : index;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function scrollParent(element: HTMLElement | null) {
|
||||
for (let node = element?.parentElement; node; node = node.parentElement) {
|
||||
const { overflowY } = getComputedStyle(node);
|
||||
if (overflowY === "auto" || overflowY === "scroll") return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True inside a TileStack or TileColumn section, which draws its own line. */
|
||||
const EdgeOwnedContext = createContext(false);
|
||||
|
||||
export function EdgeOwned({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<EdgeOwnedContext.Provider value={true}>
|
||||
{children}
|
||||
</EdgeOwnedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the element's end reaches the bottom of the visible scroll area,
|
||||
* where a closing line would sit on the screen edge and only add noise.
|
||||
*/
|
||||
function useReachesBottom(ref: React.RefObject<HTMLElement | null>) {
|
||||
const [reaches, setReaches] = useState(false);
|
||||
const reachesRef = useRef(reaches);
|
||||
reachesRef.current = reaches;
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
const scroller = scrollParent(element);
|
||||
if (!element || !scroller) return;
|
||||
// Measured without the line itself, so showing it never flips the answer.
|
||||
const update = () => {
|
||||
const end =
|
||||
element.getBoundingClientRect().bottom -
|
||||
scroller.getBoundingClientRect().top +
|
||||
scroller.scrollTop -
|
||||
(reachesRef.current ? 0 : 1);
|
||||
setReaches(end >= scroller.clientHeight - 0.5);
|
||||
};
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(scroller);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
return reaches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps tiles whose last row draws a line under itself. The line stays when
|
||||
* there is room below and is clipped when the tiles reach the bottom of the
|
||||
* screen, the same rule the tile grid uses. Inside a stack section the
|
||||
* section owns the line, so it is always clipped.
|
||||
*/
|
||||
export function BottomEdge({
|
||||
className,
|
||||
innerClassName,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
innerClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const owned = useContext(EdgeOwnedContext);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reaches = useReachesBottom(ref);
|
||||
return (
|
||||
<div ref={ref} className={cn("min-w-0 overflow-clip", className)}>
|
||||
<div className={cn((owned || reaches) && "-mb-px", innerClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
export { TileGrid, type TileGridProps } from "./TileGrid";
|
||||
export { TileFrame } from "./TileFrame";
|
||||
export { TilePicker, type TileKind, type TilePickerEntry } from "./TilePicker";
|
||||
export { TileWall } from "./TileWall";
|
||||
export { TileStack } from "./TileStack";
|
||||
export { TileColumn } from "./TileColumn";
|
||||
export { TileEditBar } from "./TileEditBar";
|
||||
export { SeamlessTileContext, useInSeamlessTile } from "./context";
|
||||
export * from "./layout";
|
||||
@@ -10,9 +10,13 @@ export function WidgetTitle({ title, icon, fallback }: WidgetTitleProps) {
|
||||
const label = title || fallback;
|
||||
if (!label) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 shrink-0">
|
||||
{icon && <span className="text-accent-brand shrink-0">{icon}</span>}
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
{icon && (
|
||||
<span className="flex shrink-0 text-muted-foreground [&_svg]:size-3">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1676,6 +1676,12 @@
|
||||
"title": "Columns",
|
||||
"auto": "Auto",
|
||||
"count": "{{count}} wide"
|
||||
},
|
||||
"pager": {
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"page": "Page {{page}}",
|
||||
"pageOf": "Page {{page}} of {{pages}}"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
@@ -1715,6 +1721,8 @@
|
||||
"action": "Actions",
|
||||
"resource": "Lists",
|
||||
"session": "Sessions"
|
||||
}
|
||||
},
|
||||
"reset": "Reset",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -128,9 +128,15 @@ export {
|
||||
type GridSlot,
|
||||
} from "@/components/card-grid/types";
|
||||
export {
|
||||
SeamlessTileContext,
|
||||
TileEditBar,
|
||||
TileFrame,
|
||||
TileGrid,
|
||||
TilePicker,
|
||||
TileColumn,
|
||||
TileStack,
|
||||
TileWall,
|
||||
useInSeamlessTile,
|
||||
appendTile,
|
||||
flowTiles,
|
||||
removeTile,
|
||||
@@ -217,6 +223,19 @@ export { WidgetTitle } from "@/lib/widget-title";
|
||||
export { runVisibleInterval } from "@/lib/visible-interval";
|
||||
export { useIsMobile } from "@/hooks/use-mobile";
|
||||
export { EmptyState } from "@/components/empty-state";
|
||||
export { NoticePanel } from "@/components/notice-panel";
|
||||
export { LoadingState } from "@/components/loading-state";
|
||||
export {
|
||||
StatusBadge,
|
||||
StatusDot,
|
||||
statusTone,
|
||||
type StatusTone,
|
||||
} from "@/components/status";
|
||||
export { StatTile } from "@/components/stat-tile";
|
||||
export { InfoGrid, type InfoItem } from "@/components/info-grid";
|
||||
export { Pager, pageOf, usePaged } from "@/components/pager";
|
||||
export { DataTable, DataTableRow } from "@/components/data-table";
|
||||
export { SideNav, SideNavItem, type SideNavEntry } from "@/components/side-nav";
|
||||
export { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog";
|
||||
export { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog";
|
||||
|
||||
@@ -299,8 +318,10 @@ export {
|
||||
Facts,
|
||||
GroupHeading,
|
||||
PANEL,
|
||||
PanelFrame,
|
||||
PanelSearch,
|
||||
PanelShell,
|
||||
RefreshButton,
|
||||
Segmented,
|
||||
ViewToggle,
|
||||
type GridSize,
|
||||
|
||||
@@ -17,8 +17,9 @@ import {
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Card } from "@/components/card";
|
||||
import { Facts, PANEL } from "@/components/panel-layout";
|
||||
import { Facts } from "@/components/panel-layout";
|
||||
import { TileStack } from "@/components/tile-grid/TileStack";
|
||||
import { TileWall } from "@/components/tile-grid/TileWall";
|
||||
import { FakeSwitch, SectionCard } from "@/components/section-card";
|
||||
import {
|
||||
Select,
|
||||
@@ -52,7 +53,7 @@ import { PluginReleaseNotes, PluginVideo } from "./PluginReleaseNotes";
|
||||
import type { PluginsManager } from "./use-plugins-manager";
|
||||
|
||||
const LINK_ROW =
|
||||
"flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground";
|
||||
"flex w-full items-center gap-2 px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground";
|
||||
|
||||
export function PluginDetail({
|
||||
plugin,
|
||||
@@ -93,10 +94,8 @@ export function PluginDetail({
|
||||
const tryableBeta = betaToTry(plugin);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mx-auto flex w-full max-w-5xl flex-col ${PANEL.gap} ${PANEL.body}`}
|
||||
>
|
||||
<Card className="flex-row flex-wrap items-start gap-4 px-4 py-4">
|
||||
<TileStack>
|
||||
<div className="flex flex-row flex-wrap items-start gap-3 p-3">
|
||||
<PluginIconBox
|
||||
name={plugin.icon}
|
||||
size="lg"
|
||||
@@ -223,10 +222,10 @@ export function PluginDetail({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{plugin.installed && failed && plugin.lastError && (
|
||||
<div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
|
||||
<div className="flex items-start gap-2 bg-destructive/5 px-3 py-2.5">
|
||||
<TriangleAlert className="mt-px size-3.5 shrink-0 text-destructive" />
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
@@ -242,7 +241,7 @@ export function PluginDetail({
|
||||
)}
|
||||
|
||||
{plugin.status === "server" && (
|
||||
<div className="flex items-start gap-2 border border-border bg-muted/30 px-3 py-2.5">
|
||||
<div className="flex items-start gap-2 bg-muted/30 px-3 py-2.5">
|
||||
<Server className="mt-px size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.runsOnServerHint")}
|
||||
@@ -250,13 +249,13 @@ export function PluginDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-2 lg:col-span-2">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3">
|
||||
<TileStack className="-mb-px border-border lg:col-span-2 lg:border-r">
|
||||
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
|
||||
|
||||
{plugin.features.length > 0 && (
|
||||
<SectionCard title={t("plugins.manager.features")} icon={null}>
|
||||
<ul className="grid grid-cols-1 gap-x-4 gap-y-1.5 px-4 py-3 sm:grid-cols-2">
|
||||
<ul className="grid grid-cols-1 gap-x-4 gap-y-1.5 py-3 sm:grid-cols-2">
|
||||
{plugin.features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
@@ -277,11 +276,11 @@ export function PluginDetail({
|
||||
key={capability}
|
||||
capability={capability}
|
||||
showId
|
||||
className="px-4 py-2.5"
|
||||
className="py-2.5"
|
||||
/>
|
||||
))}
|
||||
{plugin.capabilities.length === 0 && (
|
||||
<div className="px-4 py-2.5 text-[11px] text-muted-foreground">
|
||||
<div className="py-2.5 text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.consent.none")}
|
||||
</div>
|
||||
)}
|
||||
@@ -292,10 +291,7 @@ export function PluginDetail({
|
||||
<SectionCard title={t("plugins.manager.envVars")} icon={null}>
|
||||
<div className="divide-y divide-border">
|
||||
{plugin.env.map((v) => (
|
||||
<div
|
||||
key={v.name}
|
||||
className="flex flex-col gap-0.5 px-4 py-2.5"
|
||||
>
|
||||
<div key={v.name} className="flex flex-col gap-0.5 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="font-mono text-xs font-semibold">
|
||||
{v.name}
|
||||
@@ -331,12 +327,12 @@ export function PluginDetail({
|
||||
onDelete={() => manager.removeData(plugin)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TileStack>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<TileStack className="-mb-px">
|
||||
{contributions.length > 0 && (
|
||||
<SectionCard title={t("plugins.manager.addsToTermix")} icon={null}>
|
||||
<div className="flex flex-col gap-2 px-4 py-3">
|
||||
<div className="flex flex-col gap-2 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{contributions.map((c) => (
|
||||
<span
|
||||
@@ -358,7 +354,7 @@ export function PluginDetail({
|
||||
|
||||
{plugin.inRegistry && compatible.length > 0 && (
|
||||
<SectionCard title={t("plugins.manager.version")} icon={null}>
|
||||
<div className="flex flex-col gap-2 px-4 py-3">
|
||||
<div className="flex flex-col gap-2 py-3">
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{plugin.installed
|
||||
? t("plugins.manager.versionPickHintInstalled")
|
||||
@@ -414,7 +410,7 @@ export function PluginDetail({
|
||||
{plugin.installed && (
|
||||
<SectionCard title={t("plugins.manager.updates")} icon={null}>
|
||||
<div className="divide-y divide-border">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("plugins.manager.autoUpdate")}
|
||||
@@ -431,7 +427,7 @@ export function PluginDetail({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||
<FlaskConical className="size-3 text-muted-foreground" />
|
||||
@@ -455,7 +451,7 @@ export function PluginDetail({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3 py-3">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium">
|
||||
<Pin className="size-3 text-muted-foreground" />
|
||||
@@ -565,9 +561,9 @@ export function PluginDetail({
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</TileStack>
|
||||
</div>
|
||||
</div>
|
||||
</TileStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -625,16 +621,16 @@ function DataCard({
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<div className="px-4 py-3 text-[11px] text-muted-foreground">
|
||||
<div className="py-3 text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.data.loadFailed")}
|
||||
</div>
|
||||
) : !data ? (
|
||||
<div className="px-4 py-3 text-[11px] text-muted-foreground">
|
||||
<div className="py-3 text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="-mx-3 -my-1 flex flex-col md:-mx-4">
|
||||
<div className="grid grid-cols-2 gap-px bg-border sm:grid-cols-4">
|
||||
<div className="-mx-3 -my-1 flex flex-col">
|
||||
<TileWall gridClassName="grid-cols-2 sm:grid-cols-4">
|
||||
<Stat
|
||||
label={t("plugins.manager.data.tables")}
|
||||
value={`${data.tables.length}`}
|
||||
@@ -655,13 +651,13 @@ function DataCard({
|
||||
label={t("plugins.manager.data.files")}
|
||||
value={formatBytes(data.filesBytes)}
|
||||
/>
|
||||
</div>
|
||||
</TileWall>
|
||||
{data.tables.length > 0 && (
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{data.tables.map((table) => (
|
||||
<div
|
||||
key={table.name}
|
||||
className="flex items-center gap-2 px-4 py-1.5"
|
||||
className="flex items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">
|
||||
{table.name}
|
||||
@@ -675,7 +671,7 @@ function DataCard({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border px-4 py-2 text-[11px] text-muted-foreground">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border py-2 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{t("plugins.manager.data.migrations", {
|
||||
count: data.migrations.length,
|
||||
@@ -701,7 +697,7 @@ function Stat({
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 bg-card px-4 py-2.5">
|
||||
<div className="flex flex-col gap-0.5 px-3 py-2.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -62,7 +62,7 @@ function Release({
|
||||
.filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 py-3">
|
||||
<div className="flex flex-col gap-2 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold">{row.version}</span>
|
||||
{row.version === installedVersion && (
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
type PanelViewMode,
|
||||
} from "@/components/panel-layout";
|
||||
import { DataView, type DataColumn } from "@/components/data-view";
|
||||
import { StatusDot } from "@/components/status";
|
||||
import { TileStack } from "@/components/tile-grid/TileStack";
|
||||
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -321,7 +323,7 @@ function Toolbar({
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-3 py-2">
|
||||
<div className="relative min-w-40 max-w-md flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -346,8 +348,13 @@ function Group({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-2.5">
|
||||
<GroupHeading title={title} count={count} />
|
||||
<section className="flex flex-col">
|
||||
<GroupHeading
|
||||
plain
|
||||
title={title}
|
||||
count={count}
|
||||
className="border-b border-border px-3 py-1.5"
|
||||
/>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
@@ -653,7 +660,7 @@ function ActiveFilters({
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-6 flex-wrap items-center gap-1.5">
|
||||
<div className="flex min-h-6 flex-wrap items-center gap-1.5 border-b border-border px-3 py-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.filters.results", { count: shown })}
|
||||
</span>
|
||||
@@ -703,7 +710,7 @@ function Pager({
|
||||
const per = Number(size);
|
||||
if (total <= Number(PAGE_SIZES[0])) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2">
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{t("plugins.manager.pager.range", {
|
||||
from: (page - 1) * per + 1,
|
||||
@@ -895,7 +902,7 @@ function InstalledSection({
|
||||
rail={<FilterRail rows={searched} facets={facets} onFacets={setFacets} />}
|
||||
>
|
||||
{(filtersButton) => (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<div className="flex flex-col">
|
||||
<Toolbar
|
||||
query={query}
|
||||
onQuery={onQuery}
|
||||
@@ -1083,43 +1090,38 @@ function InstalledCard({
|
||||
}
|
||||
/>
|
||||
|
||||
{failed ? (
|
||||
<div className="flex items-center gap-2 border-t border-border bg-destructive/5 px-3 py-2">
|
||||
<TriangleAlert className="size-3.5 shrink-0 text-destructive" />
|
||||
<div className="flex items-center gap-1.5 px-3 pb-2.5">
|
||||
{failed ? (
|
||||
<span
|
||||
className="flex-1 truncate text-[11px] text-muted-foreground"
|
||||
className="flex min-w-0 items-center gap-1.5 text-[11px] text-destructive"
|
||||
title={plugin.lastError ?? undefined}
|
||||
>
|
||||
{plugin.status === "blocked"
|
||||
? t("plugins.manager.status.blocked")
|
||||
: t("plugins.manager.status.failed")}
|
||||
<TriangleAlert className="size-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{plugin.status === "blocked"
|
||||
? t("plugins.manager.status.blocked")
|
||||
: t("plugins.manager.status.failed")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : !running ? (
|
||||
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
||||
<span className="size-1.5 shrink-0 bg-muted-foreground/40" />
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{plugin.status === "server"
|
||||
? t("plugins.manager.status.server")
|
||||
: plugin.status === "safeMode"
|
||||
? t("plugins.manager.status.safeMode")
|
||||
: t("plugins.manager.status.disabled")}
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<StatusDot tone={running ? "ok" : "idle"} className="size-1.5" />
|
||||
<span className="truncate">
|
||||
{!running
|
||||
? plugin.status === "server"
|
||||
? t("plugins.manager.status.server")
|
||||
: plugin.status === "safeMode"
|
||||
? t("plugins.manager.status.safeMode")
|
||||
: t("plugins.manager.status.disabled")
|
||||
: plugin.pinnedVersion
|
||||
? t("plugins.manager.pinnedTo", {
|
||||
version: plugin.pinnedVersion,
|
||||
})
|
||||
: t("plugins.manager.status.running")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
||||
<span className="size-1.5 shrink-0 bg-green-500" />
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{plugin.pinnedVersion
|
||||
? t("plugins.manager.pinnedTo", {
|
||||
version: plugin.pinnedVersion,
|
||||
})
|
||||
: t("plugins.manager.status.running")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1159,11 +1161,7 @@ function InstalledCard({
|
||||
: t("plugins.manager.enable")}
|
||||
</Button>
|
||||
)}
|
||||
<DocsLink
|
||||
href={plugin.docs}
|
||||
variant="icon"
|
||||
className="ml-auto size-6"
|
||||
/>
|
||||
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
|
||||
{!plugin.defaultLook && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1171,10 +1169,7 @@ function InstalledCard({
|
||||
disabled={busy || locked}
|
||||
title={t("plugins.manager.uninstall")}
|
||||
aria-label={t("plugins.manager.uninstall")}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-destructive",
|
||||
!plugin.docs && "ml-auto",
|
||||
)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
onClick={() => void manager.uninstall(plugin)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
@@ -1442,7 +1437,7 @@ function BrowseSection({
|
||||
}
|
||||
>
|
||||
{(filtersButton) => (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<div className="flex flex-col">
|
||||
<Toolbar
|
||||
query={query}
|
||||
onQuery={onQuery}
|
||||
@@ -1551,7 +1546,7 @@ function BrowseCard({
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
||||
<div className="flex items-center gap-2 px-3 pb-2.5">
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] text-muted-foreground">
|
||||
{plugin.unsupported && !plugin.installed
|
||||
? ""
|
||||
@@ -1646,9 +1641,9 @@ function UpdatesSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<TileStack>
|
||||
{plain.length > 0 && (
|
||||
<Card className="flex-row items-center gap-3 px-3 py-2.5">
|
||||
<div className="flex items-center gap-3 px-3 py-2.5">
|
||||
<span className="flex-1 text-xs text-muted-foreground">
|
||||
{t("plugins.manager.updatesReady", { count: plain.length })}
|
||||
{gated.length > 0 &&
|
||||
@@ -1664,20 +1659,17 @@ function UpdatesSection({
|
||||
<Download className="size-3.5" />
|
||||
{t("plugins.manager.updateAll")}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{[...gated, ...plain, ...pinned].map((p) => (
|
||||
<UpdateRow
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
manager={manager}
|
||||
onOpen={() => onOpen(p.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{[...gated, ...plain, ...pinned].map((p) => (
|
||||
<UpdateRow
|
||||
key={p.id}
|
||||
plugin={p}
|
||||
manager={manager}
|
||||
onOpen={() => onOpen(p.id)}
|
||||
/>
|
||||
))}
|
||||
</TileStack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export function HostPluginSections({
|
||||
if (plugins.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col">
|
||||
{plugins.map((plugin) => (
|
||||
<HostPluginSection
|
||||
key={plugin.id}
|
||||
|
||||
@@ -73,9 +73,7 @@ function withTabSuspense(node: React.ReactNode) {
|
||||
function PanelTabFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full w-full justify-center overflow-y-auto bg-background">
|
||||
<div className="flex flex-col flex-1 min-h-0 w-full max-w-5xl">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-h-0 w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,8 +47,9 @@ describe("DataView", () => {
|
||||
empty={null}
|
||||
/>,
|
||||
);
|
||||
const grid = container.firstElementChild as HTMLElement;
|
||||
const grid = container.querySelector(".grid") as HTMLElement;
|
||||
expect(grid.className).not.toContain("md:grid-cols-2");
|
||||
expect(grid.className).not.toContain("gap-");
|
||||
expect(grid.style.gridTemplateColumns).toContain("/ 3");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
} from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { Pager, pageOf, usePaged } from "../../components/pager";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("pageOf", () => {
|
||||
it("clamps the page to what exists", () => {
|
||||
const items = [1, 2, 3, 4, 5];
|
||||
expect(pageOf(items, 9, 2)).toEqual({ rows: [5], page: 2, pages: 3 });
|
||||
expect(pageOf([], 0, 2)).toEqual({ rows: [], page: 0, pages: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePaged", () => {
|
||||
it("goes back to the first page when reset changes", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ reset }) => usePaged([1, 2, 3], 1, reset),
|
||||
{ initialProps: { reset: "a" } },
|
||||
);
|
||||
act(() => result.current.setPage(2));
|
||||
expect(result.current.rows).toEqual([3]);
|
||||
rerender({ reset: "b" });
|
||||
expect(result.current.page).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pager", () => {
|
||||
it("hides with one page", () => {
|
||||
const { container } = render(
|
||||
<Pager page={0} pages={1} onPage={() => {}} />,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("moves between pages", () => {
|
||||
const onPage = vi.fn();
|
||||
render(<Pager page={0} pages={3} onPage={onPage} />);
|
||||
expect(screen.getByText("panel.pager.previous")).toBeDisabled();
|
||||
fireEvent.click(screen.getByText("panel.pager.next"));
|
||||
expect(onPage).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { StatusBadge, StatusDot, statusTone } from "../../components/status";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("statusTone", () => {
|
||||
it("maps common state words", () => {
|
||||
expect(statusTone("Running")).toBe("ok");
|
||||
expect(statusTone("CrashLoopBackOff")).toBe("bad");
|
||||
expect(statusTone("pending approval")).toBe("warn");
|
||||
expect(statusTone("exited")).toBe("idle");
|
||||
});
|
||||
|
||||
it("treats unknown and empty states as idle", () => {
|
||||
expect(statusTone("banana")).toBe("idle");
|
||||
expect(statusTone(null)).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusBadge", () => {
|
||||
it("shows the state with its tone", () => {
|
||||
render(<StatusBadge state="failed" />);
|
||||
expect(screen.getByText("failed")).toHaveClass("text-destructive");
|
||||
});
|
||||
|
||||
it("lets a tone win over the state", () => {
|
||||
render(
|
||||
<StatusBadge state="failed" tone="ok">
|
||||
Fine
|
||||
</StatusBadge>,
|
||||
);
|
||||
expect(screen.getByText("Fine")).toHaveClass("text-green-500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("StatusDot", () => {
|
||||
it("colors by state", () => {
|
||||
render(<StatusDot state="running" title="up" />);
|
||||
expect(screen.getByTitle("up")).toHaveClass("bg-green-500");
|
||||
});
|
||||
});
|
||||
@@ -279,7 +279,7 @@ describe("TileGrid", () => {
|
||||
).container.querySelector(".tg-content > div")!.className;
|
||||
expect(card()).not.toContain("bg-card");
|
||||
cleanup();
|
||||
expect(card(12)).toContain("border border-border bg-card");
|
||||
expect(card(12)).toContain("border border-border");
|
||||
});
|
||||
|
||||
it("stacks cards with no edit controls on narrow screens", () => {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { TileWall } from "../../../components/tile-grid/TileWall";
|
||||
import { TileStack } from "../../../components/tile-grid/TileStack";
|
||||
import { TileColumn } from "../../../components/tile-grid/TileColumn";
|
||||
import { MetricCard } from "../../../components/metric-card";
|
||||
import { SectionCard } from "../../../components/section-card";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
initReactI18next: { type: "3rdParty", init: () => {} },
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const cardOf = (text: string) =>
|
||||
screen.getByText(text).closest("[data-tile-card]") as HTMLElement;
|
||||
|
||||
describe("TileWall", () => {
|
||||
it("wraps each tile in a cell with the shared lines", () => {
|
||||
render(
|
||||
<TileWall size="2">
|
||||
<span>one</span>
|
||||
{null}
|
||||
<span>two</span>
|
||||
</TileWall>,
|
||||
);
|
||||
const cell = screen.getByText("one").parentElement!;
|
||||
expect(cell).toHaveClass("border-b", "border-r");
|
||||
expect(cell.parentElement?.children).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops the box of cards inside it", () => {
|
||||
render(
|
||||
<TileWall>
|
||||
<MetricCard title="CPU" icon={null}>
|
||||
x
|
||||
</MetricCard>
|
||||
</TileWall>,
|
||||
);
|
||||
expect(cardOf("CPU")).not.toHaveClass("border");
|
||||
});
|
||||
|
||||
it("uses column classes in place of size", () => {
|
||||
render(
|
||||
<TileWall gridClassName="grid-cols-3">
|
||||
<span>a</span>
|
||||
</TileWall>,
|
||||
);
|
||||
const grid = screen.getByText("a").parentElement!.parentElement!;
|
||||
expect(grid).toHaveClass("grid-cols-3");
|
||||
expect(grid.style.gridTemplateColumns).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bottom line", () => {
|
||||
const edgeOf = (text: string) =>
|
||||
screen.getByText(text).closest(".overflow-clip")!.firstElementChild;
|
||||
|
||||
it("stays when the tiles do not reach the bottom of the screen", () => {
|
||||
render(
|
||||
<TileWall>
|
||||
<span>alone</span>
|
||||
</TileWall>,
|
||||
);
|
||||
expect(edgeOf("alone")).not.toHaveClass("-mb-px");
|
||||
});
|
||||
|
||||
it("is left to the section inside a stack", () => {
|
||||
render(
|
||||
<TileStack>
|
||||
<TileWall>
|
||||
<span>nested</span>
|
||||
</TileWall>
|
||||
</TileStack>,
|
||||
);
|
||||
expect(edgeOf("nested")).toHaveClass("-mb-px");
|
||||
});
|
||||
|
||||
it("is cut off once the tiles reach the bottom of the scroll area", () => {
|
||||
const rect = vi
|
||||
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
|
||||
.mockReturnValue({ top: 0, bottom: 500 } as DOMRect);
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
render(
|
||||
<div style={{ overflowY: "auto" }}>
|
||||
<TileWall>
|
||||
<span>full</span>
|
||||
</TileWall>
|
||||
</div>,
|
||||
);
|
||||
expect(edgeOf("full")).toHaveClass("-mb-px");
|
||||
rect.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TileStack", () => {
|
||||
it("draws a line under each section", () => {
|
||||
render(
|
||||
<TileStack>
|
||||
<span>first</span>
|
||||
<span>second</span>
|
||||
</TileStack>,
|
||||
);
|
||||
expect(screen.getByText("first").parentElement).toHaveClass("border-b");
|
||||
expect(screen.getByText("second").parentElement).toHaveClass("border-b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cards outside a tile layout", () => {
|
||||
it("keep their border but no fill", () => {
|
||||
render(
|
||||
<SectionCard title="Shell" icon={null}>
|
||||
body
|
||||
</SectionCard>,
|
||||
);
|
||||
const card = cardOf("Shell");
|
||||
expect(card).toHaveClass("border");
|
||||
expect(card).not.toHaveClass("bg-card");
|
||||
});
|
||||
|
||||
it("drop the border inside a TileColumn", () => {
|
||||
render(
|
||||
<TileColumn>
|
||||
<div>
|
||||
<SectionCard title="Nested" icon={null}>
|
||||
body
|
||||
</SectionCard>
|
||||
</div>
|
||||
</TileColumn>,
|
||||
);
|
||||
expect(cardOf("Nested")).not.toHaveClass("border");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user