Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 43 additions & 17 deletions src/content/panel/components/ComponentDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import {
buildUrl,
copyAsCssSelector,
copyAsFetchSnippet,
ensureProtocol,
unpublishedUri,
} from '@/lib/clay-uri';
import { copyToClipboard } from '@/lib/clipboard';
import { captureElementToClipboard } from '@/lib/screenshot';
import type { RuntimeMessage } from '@/lib/types';
import { getPanelHost } from '../../shadow-host';
import { useCopyAction } from '../hooks/useCopyAction';
import { useEnvHost, useStore } from '../store';
import { CopyableUri } from './CopyableUri';
import { Icon } from './Icon';
Expand All @@ -21,6 +22,10 @@ export function ComponentDetails() {
const selected = useStore((s) => s.selected);
const pushToast = useStore((s) => s.pushToast);
const envHost = useEnvHost();
// Each "Copy as…" button needs its own inline-feedback signal so a click
// on cURL doesn't make every button flash "Copied". The hook tracks the
// most-recently-used key; siblings compare against it.
const { copy, copiedKey } = useCopyAction();

if (!selected) {
return (
Expand All @@ -34,11 +39,6 @@ export function ComponentDetails() {
chrome.runtime.sendMessage({ type: 'OPEN_TAB', url } satisfies RuntimeMessage);
};

const copy = async (text: string, label: string) => {
const ok = await copyToClipboard(text);
pushToast(ok ? `${label} copied` : 'Copy failed', ok ? 'success' : 'error');
};

const screenshot = async () => {
try {
const ok = await captureElementToClipboard(selected.element, getPanelHost());
Expand Down Expand Up @@ -103,26 +103,52 @@ export function ComponentDetails() {
<details className="cs-copy-as">
<summary>Copy as…</summary>
<div className="cs-link-row">
<button className="cs-link" onClick={() => copy(selected.uri, 'URI')}>
<Icon name="copy" size={11} /> URI
<button
className={`cs-link ${copiedKey === 'uri' ? 'cs-link-copied' : ''}`}
onClick={() => copy(ensureProtocol(selected.uri), 'URI', 'uri')}
>
<Icon name={copiedKey === 'uri' ? 'check' : 'copy'} size={11} />{' '}
{copiedKey === 'uri' ? 'Copied' : 'URI'}
</button>
<button
className="cs-link"
onClick={() => copy(buildCurlCommand(selected.uri, '.json', envHost), 'cURL command')}
className={`cs-link ${copiedKey === 'curl' ? 'cs-link-copied' : ''}`}
onClick={() =>
copy(buildCurlCommand(selected.uri, '.json', envHost), 'cURL command', 'curl')
}
>
cURL
{copiedKey === 'curl' ? (
<>
<Icon name="check" size={11} /> Copied
</>
) : (
'cURL'
)}
</button>
<button
className="cs-link"
onClick={() => copy(copyAsFetchSnippet(selected.uri, envHost), 'fetch() snippet')}
className={`cs-link ${copiedKey === 'fetch' ? 'cs-link-copied' : ''}`}
onClick={() =>
copy(copyAsFetchSnippet(selected.uri, envHost), 'fetch() snippet', 'fetch')
}
>
fetch()
{copiedKey === 'fetch' ? (
<>
<Icon name="check" size={11} /> Copied
</>
) : (
'fetch()'
)}
</button>
<button
className="cs-link"
onClick={() => copy(copyAsCssSelector(selected.uri), 'CSS selector')}
className={`cs-link ${copiedKey === 'css' ? 'cs-link-copied' : ''}`}
onClick={() => copy(copyAsCssSelector(selected.uri), 'CSS selector', 'css')}
>
CSS
{copiedKey === 'css' ? (
<>
<Icon name="check" size={11} /> Copied
</>
) : (
'CSS'
)}
</button>
</div>
</details>
Expand Down
50 changes: 31 additions & 19 deletions src/content/panel/components/CopyableUri.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { copyToClipboard } from '@/lib/clipboard';
import { useStore } from '../store';
import { ensureProtocol } from '@/lib/clay-uri';
import { useCopyAction } from '../hooks/useCopyAction';
import { Icon } from './Icon';

interface CopyableUriProps {
/** The string actually written to the clipboard. */
/**
* The Clay URI being represented. The actual string written to the
* clipboard is `ensureProtocol(uri)` so the user always pastes a
* fully-qualified URL β€” bare URIs aren't useful in a browser bar,
* curl, or chat tool.
*/
readonly uri: string;
/**
* Optional shorter text to display in the row. Defaults to `uri`. Useful
* for component rows where the visible label is the instance ID but the
* value worth copying is the full URI.
* Optional shorter text to display in the row. Defaults to `uri`.
* Useful for component rows where the visible label is the instance ID
* but the value worth copying is the full URI.
*/
readonly displayText?: string;
/** Used in the toast + accessible label, e.g. "Page URI" or "Component URI". */
Expand All @@ -19,34 +24,41 @@ interface CopyableUriProps {
* A URI rendered in muted monospace with a copy-icon button on the right
* of the same row. Hover (and keyboard focus) brightens the button so the
* affordance is discoverable but not visually noisy at rest.
*
* After a successful copy the icon swaps to a checkmark and the button
* picks up the `cs-uri-row-copy-copied` modifier class for ~1.5s, giving
* the user co-located confirmation without forcing them to hunt for the
* toast at the bottom of the panel.
*/
export function CopyableUri({ uri, displayText, label }: CopyableUriProps) {
const pushToast = useStore((s) => s.pushToast);
const { copy, copiedKey } = useCopyAction();
const copied = copiedKey === 'default';

const onCopy = async () => {
const ok = await copyToClipboard(uri);
pushToast(ok ? `${label} copied` : 'Copy failed', ok ? 'success' : 'error');
const onCopy = () => {
void copy(ensureProtocol(uri), label);
};

// When the displayed text differs from the value being copied (component
// rows show the instance id, copy yields the full URI), surface the full
// URI in the tooltip so it's still discoverable.
const tooltip =
displayText && displayText !== uri
? `Copy ${label} to clipboard\n${uri}`
// The display text in the row stays as-is (short, clean, no scheme). The
// tooltip surfaces the *exact* string that will be copied so power users
// can verify the URL form before pasting.
const copiedText = ensureProtocol(uri);
const tooltip = copied
? 'Copied!'
: displayText && displayText !== copiedText
? `Copy ${label} to clipboard\n${copiedText}`
: `Copy ${label} to clipboard`;

return (
<div className="cs-uri-row">
<p className="cs-instance cs-uri-row-text">{displayText ?? uri}</p>
<button
type="button"
className="cs-icon-btn cs-uri-row-copy"
className={`cs-icon-btn cs-uri-row-copy ${copied ? 'cs-uri-row-copy-copied' : ''}`}
onClick={onCopy}
title={tooltip}
aria-label={`Copy ${label.toLowerCase()}`}
aria-label={copied ? `${label} copied` : `Copy ${label.toLowerCase()}`}
>
<Icon name="copy" size={12} />
<Icon name={copied ? 'check' : 'copy'} size={12} />
</button>
</div>
);
Expand Down
55 changes: 37 additions & 18 deletions src/content/panel/components/ExportMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { buildManifest, formatManifest } from '@/lib/exporter';
import { copyToClipboard } from '@/lib/clipboard';
import type { ExportFormat } from '@/lib/types';
import { useCopyAction } from '../hooks/useCopyAction';
import { useStore } from '../store';
import { Icon } from './Icon';

const OPTIONS: Array<{ format: ExportFormat; label: string; help: string }> = [
{ format: 'json', label: 'JSON', help: 'Full structured data' },
Expand All @@ -26,6 +27,10 @@ export function ExportMenu() {
const page = useStore((s) => s.page);
const components = useStore((s) => s.components);
const pushToast = useStore((s) => s.pushToast);
// Each format gets its own copy key so a click on JSON doesn't briefly
// tag CSV as "Copied". The menu auto-closes a beat *after* the copy
// succeeds so the user sees the inline confirmation before it goes away.
const { copy, copiedKey } = useCopyAction();

useEffect(() => {
if (!open) return;
Expand Down Expand Up @@ -72,16 +77,19 @@ export function ExportMenu() {
};

const exportAs = async (format: ExportFormat) => {
setOpen(false);
try {
const text = formatManifest(buildManifest(page, components), format);
const ok = await copyToClipboard(text);
pushToast(
ok ? `${format.toUpperCase()} manifest copied to clipboard` : 'Copy failed',
ok ? 'success' : 'error'
);
const ok = await copy(text, `${format.toUpperCase()} manifest`, format);
if (ok) {
// Hold the menu open briefly so the user can see the inline "Copied"
// affordance light up before we collapse it.
setTimeout(() => setOpen(false), 900);
} else {
setOpen(false);
}
} catch (err) {
pushToast(err instanceof Error ? err.message : 'Export failed', 'error');
setOpen(false);
}
};

Expand All @@ -102,17 +110,28 @@ export function ExportMenu() {
role="menu"
style={{ top: coords.top, right: coords.right }}
>
{OPTIONS.map((opt) => (
<button
key={opt.format}
role="menuitem"
onClick={() => void exportAs(opt.format)}
className="cs-export-item"
>
<span className="cs-export-label">Copy as {opt.label}</span>
<span className="cs-export-help">{opt.help}</span>
</button>
))}
{OPTIONS.map((opt) => {
const copied = copiedKey === opt.format;
return (
<button
key={opt.format}
role="menuitem"
onClick={() => void exportAs(opt.format)}
className={`cs-export-item ${copied ? 'cs-export-item-copied' : ''}`}
>
<span className="cs-export-label">
{copied ? (
<>
<Icon name="check" size={12} /> Copied
</>
) : (
<>Copy as {opt.label}</>
)}
</span>
<span className="cs-export-help">{opt.help}</span>
</button>
);
})}
</div>
)}
</div>
Expand Down
10 changes: 10 additions & 0 deletions src/content/panel/components/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,16 @@ const ICONS = {
<path d="M10 2v3h3" stroke="currentColor" strokeWidth="1.4" fill="none" />
</>
),
check: (
<path
d="M3 8.5l3 3 7-7"
stroke="currentColor"
strokeWidth="1.8"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
),
} as const;

export type IconName = keyof typeof ICONS;
Expand Down
19 changes: 12 additions & 7 deletions src/content/panel/components/JsonPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { buildUrl } from '@/lib/clay-uri';
import { copyToClipboard } from '@/lib/clipboard';
import { highlightJson } from '@/lib/json-highlight';
import { useCopyAction } from '../hooks/useCopyAction';
import { useEnvHost, useStore } from '../store';
import { Icon } from './Icon';

Expand All @@ -25,8 +25,9 @@ function initialStateFor(uri: string | null, host: string): FetchState {
export function JsonPreview() {
const selected = useStore((s) => s.selected);
const page = useStore((s) => s.page);
const pushToast = useStore((s) => s.pushToast);
const envHost = useEnvHost();
const { copy, copiedKey } = useCopyAction();
const copied = copiedKey === 'default';

const targetUri = selected?.uri ?? page?.pageUri ?? null;
const fetchUrl = targetUri ? buildUrl(targetUri, '.json', envHost) : null;
Expand Down Expand Up @@ -95,17 +96,21 @@ export function JsonPreview() {
return null;
}

const onCopy = async () => {
const ok = await copyToClipboard(JSON.stringify(state.data, null, 2));
pushToast(ok ? 'JSON copied' : 'Copy failed', ok ? 'success' : 'error');
const onCopy = () => {
void copy(JSON.stringify(state.data, null, 2), 'JSON');
};

return (
<section className="cs-section">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<h4 className="cs-section-title">JSON</h4>
<button className="cs-icon-btn" onClick={onCopy} aria-label="Copy JSON">
<Icon name="copy" />
<button
className={`cs-icon-btn ${copied ? 'cs-icon-btn-copied' : ''}`}
onClick={onCopy}
aria-label={copied ? 'JSON copied' : 'Copy JSON'}
title={copied ? 'Copied!' : 'Copy JSON to clipboard'}
>
<Icon name={copied ? 'check' : 'copy'} />
</button>
</div>
<pre className="cs-json" dangerouslySetInnerHTML={{ __html: highlightJson(state.data) }} />
Expand Down
21 changes: 11 additions & 10 deletions src/content/panel/components/SeoTab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { copyToClipboard } from '@/lib/clipboard';
import { highlightJson } from '@/lib/json-highlight';
import {
extractSeoMeta,
Expand All @@ -10,7 +9,7 @@ import {
type SeoIssue,
type SeoMeta,
} from '@/lib/seo';
import { useStore } from '../store';
import { useCopyAction } from '../hooks/useCopyAction';
import { Icon } from './Icon';

const TONE: Record<SeoIssue['severity'], string> = {
Expand Down Expand Up @@ -76,22 +75,22 @@ function JsonLdBlockCard({
index: number;
issues: readonly JsonLdIssue[];
}) {
const pushToast = useStore((s) => s.pushToast);
// Open by default if the block has any errors so the user immediately
// sees what's wrong without an extra click.
const hasError = issues.some((i) => i.severity === 'error');
const [open, setOpen] = useState(hasError);
const summary = summarizeJsonLd(block);
const headerSeverity = worstSeverity(issues);
const { copy, copiedKey } = useCopyAction();
const copied = copiedKey === 'default';

const onCopy = async (e: React.MouseEvent) => {
const onCopy = (e: React.MouseEvent) => {
// Prevent the click from toggling the <details> open state.
e.preventDefault();
e.stopPropagation();
const text =
summary.invalid && isInvalidBlock(block) ? (block.raw ?? '') : JSON.stringify(block, null, 2);
const ok = await copyToClipboard(text);
pushToast(ok ? `Copied JSON-LD block #${index + 1}` : 'Copy failed', ok ? 'success' : 'error');
void copy(text, `JSON-LD block #${index + 1}`);
};

const cardClasses = ['cs-jsonld-card'];
Expand Down Expand Up @@ -135,12 +134,14 @@ function JsonLdBlockCard({
)}
<button
type="button"
className="cs-icon-btn cs-jsonld-copy"
className={`cs-icon-btn cs-jsonld-copy ${copied ? 'cs-icon-btn-copied' : ''}`}
onClick={onCopy}
aria-label={`Copy JSON-LD block ${index + 1}`}
title="Copy JSON to clipboard"
aria-label={
copied ? `JSON-LD block ${index + 1} copied` : `Copy JSON-LD block ${index + 1}`
}
title={copied ? 'Copied!' : 'Copy JSON to clipboard'}
>
<Icon name="copy" />
<Icon name={copied ? 'check' : 'copy'} />
</button>
</summary>
{/* Body is only mounted once expanded β€” saves the syntax-highlight
Expand Down
Loading
Loading