block
Share Panel
Ratio picker, live Skill Card preview and copy-link control — the surface that turns a score into something shareable.
npx shadcn@latest add @hextor/share-panelSource
"use client";
import { useState } from "react";
import { Check, Link2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ratioMeta, SkillCard } from "@/registry/hextor/components/hextor/skill-card/skill-card";
import type { ShareRatio, SkillCardData } from "@/registry/hextor/components/hextor/skill-card/types";
import { resolveLabels, type SharePanelLabels } from "@/registry/hextor/components/hextor/share-panel/labels";
const RATIOS: ShareRatio[] = ["wide", "tall"];
/**
* The share surface: pick a ratio, see the card, copy the link.
*
* The **thumbnail preview is not decoration** — it is the acceptance test made
* permanent. A card is judged at ~200px in a chat list, not at 1200px in a design
* review, and every share card that has ever failed has failed by being designed
* at the wrong size. Rendering both sizes side by side means the next person to
* change the card sees the thumbnail break in the same commit.
*
* There is no "download PNG" button, and that is a real gap rather than an
* oversight: rasterising this needs either a canvas rewrite of the card or a
* server renderer with a bundled Thai font (`next/og` cannot use the app's
* `next/font` instance). Until then the interaction is screenshot-and-paste,
* which is what people do anyway.
*/
export function SharePanel({
data,
shareUrl,
labels,
}: {
data: SkillCardData;
shareUrl: string;
/** Overrides `defaultLabels` (Thai); pass nothing to keep them. */
labels?: Partial<SharePanelLabels>;
}) {
const [ratio, setRatio] = useState<ShareRatio>("wide");
const [copied, setCopied] = useState(false);
const copy = resolveLabels(labels);
const copyLink = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch {
// Clipboard is permission-gated and absent over plain http on a LAN IP,
// which is exactly how this gets demoed. The input below is always
// selectable, so failing silently still leaves a way to copy the link.
}
};
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{copy.heading}
</h3>
<Tabs
value={ratio}
onValueChange={(v) => setRatio(v as ShareRatio)}
className="ml-auto"
>
<TabsList className="h-7">
{RATIOS.map((r) => (
<TabsTrigger key={r} value={r} className="px-2 text-[11px]">
{ratioMeta(r).label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<p className="text-[11px] text-muted-foreground">{copy.ratioHints[ratio]}</p>
<div className="grid gap-4 lg:grid-cols-[1fr_13rem]">
<div className={ratio === "tall" ? "mx-auto w-full max-w-sm" : undefined}>
<SkillCard data={data} ratio={ratio} />
</div>
{/* The acceptance test: the same card, 208px wide, which is roughly how
wide it arrives in a LINE or Discord thread before anyone taps it. */}
<figure className="space-y-1.5">
<div className="w-52">
<SkillCard data={data} ratio={ratio} />
</div>
<figcaption className="w-52 text-[10px] leading-snug text-muted-foreground">
{copy.thumbnailCaption}
</figcaption>
</figure>
</div>
<div className="flex items-center gap-2">
<input
readOnly
value={shareUrl}
onFocus={(e) => e.currentTarget.select()}
aria-label={copy.linkInputAria}
className="min-w-0 flex-1 truncate rounded-md border bg-muted/40 px-2.5 py-1.5 text-xs text-muted-foreground"
/>
<Button variant="outline" size="sm" onClick={copyLink} className="gap-1.5">
{copied ? <Check className="size-3.5" /> : <Link2 className="size-3.5" />}
{copied ? copy.copyDone : copy.copyIdle}
</Button>
</div>
</section>
);
}
Docs
Renders the real <SkillCard> at each ratio rather than a mock, so what someone previews is what they get. Installing this pulls @hextor/skill-card with it.
Dependencies
lucide-reactbuttontabs@hextor/skill-card