feat: Updated marketing site
This commit is contained in:
@@ -9,7 +9,7 @@ import { Group } from "@/components/settings/Group";
|
||||
import { inputClass } from "@/components/settings/Field";
|
||||
|
||||
/*
|
||||
* A licence is a document — issued, dated, signed, and carrying a reference
|
||||
* A licence is a document issued, dated, signed, and carrying a reference
|
||||
* you quote to support. This page is built to read as that document rather
|
||||
* than as another settings form, which is also how adminsite/ presents the
|
||||
* same object from the issuing side: state as a pill that carries a label and
|
||||
@@ -34,7 +34,7 @@ const STATE: Record<LicenseState, { label: string; rule: string; text: string }>
|
||||
// this app's to choose. Each one says what is wrong and what to do about it.
|
||||
const REASON: Record<string, string> = {
|
||||
no_license: "No licence is installed on this instance. Paste one below to activate it.",
|
||||
bad_signature: "This licence's signature could not be verified. It may have been edited or truncated in transit — paste the original again.",
|
||||
bad_signature: "This licence's signature could not be verified. It may have been edited or truncated in transit paste the original again.",
|
||||
deployment_mismatch: "This licence was issued for a different kind of deployment, so it cannot be used here.",
|
||||
instance_mismatch: "This licence was issued for a different instance. Check the instance ID below against the one you bought against.",
|
||||
expired: "This licence has passed its expiry date. Your servers and monitors keep running, but changes are disabled until it is renewed.",
|
||||
@@ -60,25 +60,19 @@ const SUPPORT_LABELS: Record<string, string> = {
|
||||
|
||||
/*
|
||||
* Retention is a duration, not a cap, so it gets a fact rather than a meter.
|
||||
* Allowance's bar needs a numerator and there isn't one — how much of a
|
||||
* Allowance's bar needs a numerator and there isn't one how much of a
|
||||
* retention window have you "used"?
|
||||
*/
|
||||
function Retention({ days }: { days: number }) {
|
||||
const forever = days === UNLIMITED;
|
||||
return (
|
||||
<Card padding={false} className="p-4">
|
||||
<p className="font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-tertiary">
|
||||
Audit history
|
||||
</p>
|
||||
<p className="font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-tertiary">Audit history</p>
|
||||
<p className="mt-2 text-2xl font-extrabold tracking-[-0.03em] tabular-nums text-text-primary">
|
||||
{forever ? "Kept" : days}
|
||||
<span className="ml-1.5 text-sm font-medium tracking-normal text-text-secondary">
|
||||
{forever ? "indefinitely" : "days"}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-3 font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-secondary">
|
||||
{forever ? "Never trimmed" : "Older entries removed daily"}
|
||||
<span className="ml-1.5 text-sm font-medium tracking-normal text-text-secondary">{forever ? "indefinitely" : "days"}</span>
|
||||
</p>
|
||||
<p className="mt-3 font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-secondary">{forever ? "Never trimmed" : "Older entries removed daily"}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -137,9 +131,7 @@ function Feature({ label, included }: { label: string; included: boolean }) {
|
||||
{included ? "✓" : "–"}
|
||||
</span>
|
||||
<span className="text-sm text-text-primary">{label}</span>
|
||||
<span className={`ml-auto font-mono text-[0.62rem] uppercase tracking-[0.14em] ${included ? "text-success" : "text-text-tertiary"}`}>
|
||||
{included ? "Included" : "Not included"}
|
||||
</span>
|
||||
<span className={`ml-auto font-mono text-[0.62rem] uppercase tracking-[0.14em] ${included ? "text-success" : "text-text-tertiary"}`}>{included ? "Included" : "Not included"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -162,9 +154,7 @@ function RecordPanel({ license }: { license: LicenseInfo }) {
|
||||
already carries the colour. A dot would be the third telling. */}
|
||||
<h2 className={`text-2xl font-extrabold tracking-[-0.03em] ${s.text}`}>{s.label}</h2>
|
||||
{license.tier && <p className="text-sm text-text-secondary">{license.tier} tier</p>}
|
||||
{license.support_level && SUPPORT_LABELS[license.support_level] && (
|
||||
<p className="text-sm text-text-secondary">{SUPPORT_LABELS[license.support_level]}</p>
|
||||
)}
|
||||
{license.support_level && SUPPORT_LABELS[license.support_level] && <p className="text-sm text-text-secondary">{SUPPORT_LABELS[license.support_level]}</p>}
|
||||
</div>
|
||||
|
||||
{license.reason && <p className="mt-3 max-w-prose text-sm text-text-secondary">{REASON[license.reason] ?? license.reason}</p>}
|
||||
@@ -297,51 +287,51 @@ export default function LicensePage() {
|
||||
</Card>
|
||||
</Group>
|
||||
) : (
|
||||
<Group label="Add or replace">
|
||||
<Card>
|
||||
<label htmlFor="licence-blob" className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Licence key
|
||||
</label>
|
||||
<textarea
|
||||
id="licence-blob"
|
||||
className={`${inputClass} h-32 resize-y font-mono text-xs`}
|
||||
placeholder="Paste your licence key"
|
||||
value={blob}
|
||||
onChange={(e) => {
|
||||
setBlob(e.target.value);
|
||||
setFileName("");
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
{/* A bare file input cannot be styled, so the button is the
|
||||
label and the input itself is visually hidden. */}
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded border border-border bg-surface px-3 py-1.5 text-sm font-semibold text-text-primary transition-colors hover:border-text-tertiary focus-within:ring-2 focus-within:ring-accent">
|
||||
<input
|
||||
type="file"
|
||||
accept=".lic,.txt"
|
||||
className="sr-only"
|
||||
onChange={async (e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
setBlob((await f.text()).trim());
|
||||
setFileName(f.name);
|
||||
}}
|
||||
/>
|
||||
Choose a file
|
||||
<Group label="Add or replace">
|
||||
<Card>
|
||||
<label htmlFor="licence-blob" className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
Licence key
|
||||
</label>
|
||||
<span className="text-xs text-text-tertiary">{fileName ? `Loaded ${fileName}` : "or paste the key above"}</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="licence-blob"
|
||||
className={`${inputClass} h-32 resize-y font-mono text-xs`}
|
||||
placeholder="Paste your licence key"
|
||||
value={blob}
|
||||
onChange={(e) => {
|
||||
setBlob(e.target.value);
|
||||
setFileName("");
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <p className="mt-4 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</p>}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
{/* A bare file input cannot be styled, so the button is the
|
||||
label and the input itself is visually hidden. */}
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded border border-border bg-surface px-3 py-1.5 text-sm font-semibold text-text-primary transition-colors hover:border-text-tertiary focus-within:ring-2 focus-within:ring-accent">
|
||||
<input
|
||||
type="file"
|
||||
accept=".lic,.txt"
|
||||
className="sr-only"
|
||||
onChange={async (e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
setBlob((await f.text()).trim());
|
||||
setFileName(f.name);
|
||||
}}
|
||||
/>
|
||||
Choose a file
|
||||
</label>
|
||||
<span className="text-xs text-text-tertiary">{fileName ? `Loaded ${fileName}` : "or paste the key above"}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 border-t border-border-soft pt-5">
|
||||
<Button type="button" variant="primary" loading={save.isPending} disabled={!blob.trim()} onClick={() => save.mutate()}>
|
||||
{save.isPending ? "Checking…" : "Save licence"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Group>
|
||||
{error && <p className="mt-4 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</p>}
|
||||
|
||||
<div className="mt-5 border-t border-border-soft pt-5">
|
||||
<Button type="button" variant="primary" loading={save.isPending} disabled={!blob.trim()} onClick={() => save.mutate()}>
|
||||
{save.isPending ? "Checking…" : "Save licence"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+13
-17
@@ -3,23 +3,19 @@ import "./globals.css";
|
||||
import { Providers } from "@/components/Providers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Vantage",
|
||||
description: "Self-hosted SSH key management",
|
||||
title: "Vantage",
|
||||
description: "Self-hosted SSH key management",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
// data-theme is what site/ and adminsite/ switch on. This app is locked to
|
||||
// dark, so the attribute is fixed rather than toggleable — it is here so
|
||||
// the three apps declare their ground the same way.
|
||||
<html lang="en" className="dark" data-theme="dark">
|
||||
<body className="bg-background font-sans text-text-primary">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
// data-theme is what site/ and adminsite/ switch on. This app is locked to
|
||||
// dark, so the attribute is fixed rather than toggleable it is here so
|
||||
// the three apps declare their ground the same way.
|
||||
<html lang="en" className="dark" data-theme="dark">
|
||||
<body className="bg-background font-sans text-text-primary">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,20 +89,16 @@ export default function SetupPage() {
|
||||
|
||||
<Card>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{created.slug} has its own address, and sign-in is kept separate per instance. Continue to your instance's sign-in page and log in with the email and password
|
||||
you just chose.
|
||||
{created.slug} has its own address, and sign-in is kept separate per instance. Continue to your instance's sign-in page and log in with the email and password you just
|
||||
chose.
|
||||
</p>
|
||||
<code className="mt-3 block overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">{created.loginUrl}</code>
|
||||
|
||||
<div className="mt-4 rounded border border-border p-3">
|
||||
<p className="text-xs text-text-tertiary">Instance ID — needed to activate a licence</p>
|
||||
<p className="text-xs text-text-tertiary">Instance ID needed to activate a licence</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<code className="overflow-x-auto font-mono text-xs text-text-primary">{created.instanceId}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs underline"
|
||||
onClick={() => navigator.clipboard.writeText(created.instanceId)}
|
||||
>
|
||||
<button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(created.instanceId)}>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ interface Pulse {
|
||||
/*
|
||||
* Drawn from the shared tokens rather than read from them: this is canvas, so
|
||||
* the values have to be literals. They are the light-theme accent falling to
|
||||
* the app ground, with packets in the dark-theme accent — the same two blues
|
||||
* the app ground, with packets in the dark-theme accent the same two blues
|
||||
* the marketing site's hero is built on.
|
||||
*/
|
||||
const BG_TOP = "#0b2a58"; // --accent, light
|
||||
@@ -137,7 +137,7 @@ export function NetworkBackground() {
|
||||
dir: Math.random() < 0.5 ? 1 : -1,
|
||||
t: 0,
|
||||
speed: 0.18 + Math.random() * 0.22, // ~2.5-5.5s per edge
|
||||
delay: (initial ? Math.random() * 8 : Math.random() * 6),
|
||||
delay: initial ? Math.random() * 8 : Math.random() * 6,
|
||||
life: 0,
|
||||
};
|
||||
}
|
||||
@@ -290,11 +290,5 @@ export function NetworkBackground() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none fixed inset-0 -z-10 h-full w-full"
|
||||
/>
|
||||
);
|
||||
return <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 h-full w-full" />;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ const navItems: NavItem[] = [
|
||||
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
|
||||
];
|
||||
|
||||
/** Shared by the permanent aside and the offcanvas drawer — one copy of the nav. */
|
||||
/** Shared by the permanent aside and the offcanvas drawer one copy of the nav. */
|
||||
export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const { user, instance, isAdmin } = useAuth();
|
||||
@@ -159,9 +159,7 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
|
||||
<Logo className="h-8 w-8 text-logo" />
|
||||
<div className="min-w-0">
|
||||
<span className="block text-base font-extrabold leading-tight tracking-[-0.035em] text-text-primary">Vantage</span>
|
||||
{instance && (
|
||||
<span className="block truncate font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>
|
||||
)}
|
||||
{instance && <span className="block truncate font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -252,20 +250,8 @@ export function SidebarDrawer({ open, onClose }: { open: boolean; onClose: () =>
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"fixed inset-0 z-50 lg:hidden",
|
||||
open ? "visible" : "invisible pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
onClick={onClose}
|
||||
className={clsx(
|
||||
"absolute inset-0 bg-black/60 transition-opacity duration-200",
|
||||
open ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className={clsx("fixed inset-0 z-50 lg:hidden", open ? "visible" : "invisible pointer-events-none")}>
|
||||
<div aria-hidden="true" onClick={onClose} className={clsx("absolute inset-0 bg-black/60 transition-opacity duration-200", open ? "opacity-100" : "opacity-0")} />
|
||||
<div
|
||||
ref={panelRef}
|
||||
id="app-sidebar-drawer"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Card } from "@/components/ui";
|
||||
/*
|
||||
* Every block on the settings page is one of these: icon, title, one line of
|
||||
* explanation, then the controls. Members and SSO adopted it when they moved
|
||||
* off their own page — a card that looked different would read as a different
|
||||
* off their own page a card that looked different would read as a different
|
||||
* kind of thing rather than another setting.
|
||||
*/
|
||||
export function SectionCard({
|
||||
|
||||
+22
-22
@@ -3,9 +3,9 @@ import { clsx } from "clsx";
|
||||
type Variant = "success" | "warning" | "danger" | "neutral" | "accent";
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: Variant;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: Variant;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -13,31 +13,31 @@ interface BadgeProps {
|
||||
* no fill. Every badge in this app holds one short state word, which is what
|
||||
* the treatment is for.
|
||||
*
|
||||
* State is never carried by colour alone — the word itself says it, and the
|
||||
* State is never carried by colour alone the word itself says it, and the
|
||||
* three state variants add a dot so the distinction survives a monochrome
|
||||
* screen as well as it survives colour blindness.
|
||||
*/
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
success: "text-success",
|
||||
warning: "text-warning",
|
||||
danger: "text-danger",
|
||||
neutral: "text-text-secondary",
|
||||
accent: "text-accent",
|
||||
success: "text-success",
|
||||
warning: "text-warning",
|
||||
danger: "text-danger",
|
||||
neutral: "text-text-secondary",
|
||||
accent: "text-accent",
|
||||
};
|
||||
|
||||
export function Badge({ variant = "neutral", children, className }: BadgeProps) {
|
||||
const hasState = variant === "success" || variant === "warning" || variant === "danger";
|
||||
const hasState = variant === "success" || variant === "warning" || variant === "danger";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-sm border border-current px-1.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-[0.08em]",
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{hasState && <span className="h-1.5 w-1.5 rounded-full bg-current" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1.5 whitespace-nowrap rounded-sm border border-current px-1.5 py-0.5 font-mono text-[0.65rem] uppercase tracking-[0.08em]",
|
||||
variantClasses[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasState && <span className="h-1.5 w-1.5 rounded-full bg-current" />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
+73
-94
@@ -4,7 +4,7 @@ import { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
|
||||
/*
|
||||
* Below sm the table stops being a table: the head is hidden, each row becomes
|
||||
* a bordered card and each cell becomes a label/value pair. That lives here
|
||||
* rather than in the six pages that render tables — two markup trees per page
|
||||
* rather than in the six pages that render tables two markup trees per page
|
||||
* would drift apart on the first edit, and every one of those trees would mean
|
||||
* the same thing.
|
||||
*
|
||||
@@ -14,115 +14,94 @@ import { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
|
||||
*/
|
||||
|
||||
export function Table({ className, children, ...props }: HTMLAttributes<HTMLTableElement>) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table
|
||||
className={clsx("w-full border-collapse text-sm max-sm:block", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className={clsx("w-full border-collapse text-sm max-sm:block", className)} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Thead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return (
|
||||
<thead className={clsx("border-b border-border max-sm:hidden", className)} {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
return (
|
||||
<thead className={clsx("border-b border-border max-sm:hidden", className)} {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tbody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return (
|
||||
<tbody
|
||||
className={clsx(
|
||||
"divide-y divide-border",
|
||||
"max-sm:block max-sm:space-y-3 max-sm:divide-y-0 max-sm:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
return (
|
||||
<tbody className={clsx("divide-y divide-border", "max-sm:block max-sm:space-y-3 max-sm:divide-y-0 max-sm:p-3", className)} {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tr({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
|
||||
return (
|
||||
<tr
|
||||
className={clsx(
|
||||
"transition-colors hover:bg-surface-2/50",
|
||||
"max-sm:block max-sm:rounded max-sm:border max-sm:border-border max-sm:bg-surface-2/40 max-sm:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
return (
|
||||
<tr className={clsx("transition-colors hover:bg-surface-2/50", "max-sm:block max-sm:rounded max-sm:border max-sm:border-border max-sm:bg-surface-2/40 max-sm:p-3", className)} {...props}>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function Th({ className, children, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
|
||||
return (
|
||||
<th
|
||||
className={clsx(
|
||||
// site/'s keyed-label idiom: mono, small, widely tracked, dimmed.
|
||||
// A column head is a key, not prose.
|
||||
// text-secondary, not tertiary: a column head is how you navigate the
|
||||
// table, and tertiary lands under 4.5:1 at this size.
|
||||
"px-4 py-3 text-left font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
return (
|
||||
<th
|
||||
className={clsx(
|
||||
// site/'s keyed-label idiom: mono, small, widely tracked, dimmed.
|
||||
// A column head is a key, not prose.
|
||||
// text-secondary, not tertiary: a column head is how you navigate the
|
||||
// table, and tertiary lands under 4.5:1 at this size.
|
||||
"px-4 py-3 text-left font-mono text-[0.68rem] uppercase tracking-[0.13em] text-text-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
interface TdProps extends TdHTMLAttributes<HTMLTableCellElement> {
|
||||
/**
|
||||
* The column head this cell belongs to, shown beside the value below sm
|
||||
* where the real head is hidden. Omit on a trailing action cell — an action
|
||||
* needs no key, and the button then sits alone on its own row in the card.
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* The column head this cell belongs to, shown beside the value below sm
|
||||
* where the real head is hidden. Omit on a trailing action cell an action
|
||||
* needs no key, and the button then sits alone on its own row in the card.
|
||||
*/
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Td({ className, label, children, ...props }: TdProps) {
|
||||
return (
|
||||
<td
|
||||
className={clsx(
|
||||
"px-4 py-3 text-text-primary",
|
||||
"max-sm:flex max-sm:items-start max-sm:gap-4 max-sm:px-0 max-sm:py-1.5",
|
||||
// Exactly one justify class — clsx picks it. Emitting both and relying
|
||||
// on string order would not work: Tailwind's output order decides which
|
||||
// of two same-property utilities wins, not the order in this array.
|
||||
label ? "max-sm:justify-between" : "max-sm:justify-end max-sm:pt-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{label && (
|
||||
<span className="hidden shrink-0 font-mono text-[0.68rem] uppercase leading-5 tracking-[0.13em] text-text-secondary max-sm:inline">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
{/*
|
||||
* Below sm the cell is a flex row, and a flex item's default
|
||||
* min-width:auto resolves to its min-content width. A fingerprint or a
|
||||
* target URL is a single unbreakable token, so min-content was the whole
|
||||
* string: the value refused to shrink and spilled out of the card.
|
||||
* min-w-0 lets it shrink, break-words gives the token somewhere to break.
|
||||
*
|
||||
* Above sm the wrapper is display:contents, so the desktop table renders
|
||||
* exactly as it did before. Every other class here is max-sm: scoped on
|
||||
* purpose — display:contents generates no box, but inherited properties
|
||||
* (overflow-wrap among them) would still reach the children.
|
||||
*/}
|
||||
<span className="max-sm:min-w-0 max-sm:break-words sm:contents">{children}</span>
|
||||
</td>
|
||||
);
|
||||
return (
|
||||
<td
|
||||
className={clsx(
|
||||
"px-4 py-3 text-text-primary",
|
||||
"max-sm:flex max-sm:items-start max-sm:gap-4 max-sm:px-0 max-sm:py-1.5",
|
||||
// Exactly one justify class clsx picks it. Emitting both and relying
|
||||
// on string order would not work: Tailwind's output order decides which
|
||||
// of two same-property utilities wins, not the order in this array.
|
||||
label ? "max-sm:justify-between" : "max-sm:justify-end max-sm:pt-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{label && <span className="hidden shrink-0 font-mono text-[0.68rem] uppercase leading-5 tracking-[0.13em] text-text-secondary max-sm:inline">{label}</span>}
|
||||
{/*
|
||||
* Below sm the cell is a flex row, and a flex item's default
|
||||
* min-width:auto resolves to its min-content width. A fingerprint or a
|
||||
* target URL is a single unbreakable token, so min-content was the whole
|
||||
* string: the value refused to shrink and spilled out of the card.
|
||||
* min-w-0 lets it shrink, break-words gives the token somewhere to break.
|
||||
*
|
||||
* Above sm the wrapper is display:contents, so the desktop table renders
|
||||
* exactly as it did before. Every other class here is max-sm: scoped on
|
||||
* purpose display:contents generates no box, but inherited properties
|
||||
* (overflow-wrap among them) would still reach the children.
|
||||
*/}
|
||||
<span className="max-sm:min-w-0 max-sm:break-words sm:contents">{children}</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user