HextorUI
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-panel

Source

"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