Add social sharing for template previews (#2924)

* Add template social sharing menu

* Update plugin share e2e expectations

* Add additional template social share targets

* Remove Bilibili template share target

* Open social share destinations in new tabs

* Address template share review feedback

* Use canonical public plugin share URLs

* Gate public plugin share links by marketplace provenance

* Update plugin share e2e for local-only badges

* Limit public share URLs to official marketplace
This commit is contained in:
Mason 2026-05-27 18:21:35 +08:00 committed by GitHub
parent 2540e8a92b
commit e40947ac0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1457 additions and 180 deletions

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { copyToClipboard } from '../lib/copy-to-clipboard';
import { exportAsHtml, exportAsPdf, exportAsZip, openSandboxedPreviewInNewTab } from '../runtime/exports'; import { exportAsHtml, exportAsPdf, exportAsZip, openSandboxedPreviewInNewTab } from '../runtime/exports';
import { buildSrcdoc } from '../runtime/srcdoc'; import { buildSrcdoc } from '../runtime/srcdoc';
import { Icon } from './Icon'; import { Icon } from './Icon';
@ -74,6 +75,82 @@ export interface PreviewPrimaryAction {
testId?: string; testId?: string;
} }
export interface PreviewShareTarget {
title?: string;
description?: string;
url?: string | null;
}
type SocialSharePlatform =
| 'x'
| 'reddit'
| 'facebook'
| 'linkedin'
| 'instagram'
| 'xiaohongshu';
const SOCIAL_SHARE_PLATFORMS: Array<{
platform: SocialSharePlatform;
labelKey:
| 'preview.shareToX'
| 'preview.shareToReddit'
| 'preview.shareToFacebook'
| 'preview.shareToLinkedIn'
| 'preview.shareToInstagram'
| 'preview.shareToXiaohongshu';
mark: string;
mode: 'intent' | 'copy-open';
entryUrl?: string;
}> = [
{ platform: 'x', labelKey: 'preview.shareToX', mark: 'X', mode: 'intent' },
{ platform: 'reddit', labelKey: 'preview.shareToReddit', mark: 'R', mode: 'intent' },
{ platform: 'facebook', labelKey: 'preview.shareToFacebook', mark: 'f', mode: 'intent' },
{ platform: 'linkedin', labelKey: 'preview.shareToLinkedIn', mark: 'in', mode: 'intent' },
{
platform: 'instagram',
labelKey: 'preview.shareToInstagram',
mark: 'IG',
mode: 'copy-open',
entryUrl: 'https://www.instagram.com/',
},
{
platform: 'xiaohongshu',
labelKey: 'preview.shareToXiaohongshu',
mark: '小',
mode: 'copy-open',
entryUrl: 'https://www.xiaohongshu.com/',
},
];
function buildSocialShareUrl(
platform: SocialSharePlatform,
args: { url: string; title: string; text: string },
): string | null {
const params = new URLSearchParams();
switch (platform) {
case 'x':
params.set('url', args.url);
params.set('text', args.text);
return `https://twitter.com/intent/tweet?${params.toString()}`;
case 'reddit':
params.set('url', args.url);
params.set('title', args.title);
return `https://www.reddit.com/submit?${params.toString()}`;
case 'facebook':
params.set('u', args.url);
params.set('quote', args.text);
return `https://www.facebook.com/sharer/sharer.php?${params.toString()}`;
case 'linkedin':
params.set('url', args.url);
return `https://www.linkedin.com/sharing/share-offsite/?${params.toString()}`;
case 'instagram':
case 'xiaohongshu':
return null;
}
const exhaustive: never = platform;
return exhaustive;
}
interface Props { interface Props {
title: string; title: string;
subtitle?: string; subtitle?: string;
@ -106,6 +183,9 @@ interface Props {
// affordance reads consistently across HTML / design-system / media // affordance reads consistently across HTML / design-system / media
// variants. // variants.
headerExtras?: ReactNode; headerExtras?: ReactNode;
// Social-share target for the active preview. When omitted, the modal uses
// the current browser URL so non-plugin callers still get copy/share actions.
shareTarget?: PreviewShareTarget;
// Optional analytics callbacks. Fires when the user clicks the // Optional analytics callbacks. Fires when the user clicks the
// chrome-level affordances (fullscreen, share trigger, sidebar // chrome-level affordances (fullscreen, share trigger, sidebar
// toggle). Callers wire these to their surface's tracking helper. // toggle). Callers wire these to their surface's tracking helper.
@ -121,9 +201,9 @@ interface Props {
} }
// A full-screen overlay that renders an iframe of arbitrary HTML, with an // A full-screen overlay that renders an iframe of arbitrary HTML, with an
// optional tab bar for multiple views, a Share menu (PDF / HTML / ZIP / // optional tab bar for multiple views, a merged Share menu, and a Fullscreen
// open-in-new-tab), and a Fullscreen toggle. Used by both the design-system // toggle. Used by both the design-system preview and the example card preview,
// preview and the example card preview, so the two paths feel identical. // so the two paths feel identical.
export function PreviewModal({ export function PreviewModal({
title, title,
subtitle, subtitle,
@ -136,6 +216,7 @@ export function PreviewModal({
designWidth = 1280, designWidth = 1280,
primaryAction, primaryAction,
headerExtras, headerExtras,
shareTarget,
onFullscreenClick, onFullscreenClick,
onShareClick, onShareClick,
onSidebarToggleClick, onSidebarToggleClick,
@ -146,12 +227,16 @@ export function PreviewModal({
? initialViewId ? initialViewId
: views[0]?.id ?? ''; : views[0]?.id ?? '';
const [activeId, setActiveId] = useState<string>(initial); const [activeId, setActiveId] = useState<string>(initial);
const [shareOpen, setShareOpen] = useState(false); const [templateShareOpen, setTemplateShareOpen] = useState(false);
const [copyShareFeedback, setCopyShareFeedback] = useState<{
key: string;
ok: boolean;
} | null>(null);
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState<boolean>( const [sidebarOpen, setSidebarOpen] = useState<boolean>(
sidebar?.defaultOpen ?? false, sidebar?.defaultOpen ?? false,
); );
const shareRef = useRef<HTMLDivElement | null>(null); const templateShareRef = useRef<HTMLDivElement | null>(null);
const stageRef = useRef<HTMLDivElement | null>(null); const stageRef = useRef<HTMLDivElement | null>(null);
const stageFrameRef = useRef<HTMLDivElement | null>(null); const stageFrameRef = useRef<HTMLDivElement | null>(null);
const [stageSize, setStageSize] = useState<{ w: number; h: number }>({ const [stageSize, setStageSize] = useState<{ w: number; h: number }>({
@ -213,15 +298,19 @@ export function PreviewModal({
return () => document.removeEventListener('fullscreenchange', onFsChange); return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []); }, []);
// Close share popover on outside click / Escape. // Close header popovers on outside click / Escape.
useEffect(() => { useEffect(() => {
if (!shareOpen) return; if (!templateShareOpen) return;
const onDoc = (e: MouseEvent) => { const onDoc = (e: MouseEvent) => {
if (!shareRef.current) return; const target = e.target as Node;
if (!shareRef.current.contains(e.target as Node)) setShareOpen(false); if (templateShareOpen && !templateShareRef.current?.contains(target)) {
setTemplateShareOpen(false);
}
}; };
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShareOpen(false); if (e.key === 'Escape') {
setTemplateShareOpen(false);
}
}; };
document.addEventListener('mousedown', onDoc); document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
@ -229,7 +318,7 @@ export function PreviewModal({
document.removeEventListener('mousedown', onDoc); document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey); document.removeEventListener('keydown', onKey);
}; };
}, [shareOpen]); }, [templateShareOpen]);
// Lock body scroll while open. // Lock body scroll while open.
useEffect(() => { useEffect(() => {
@ -277,6 +366,34 @@ export function PreviewModal({
[activeHtml, activeDeck], [activeHtml, activeDeck],
); );
const exportTitle = exportTitleFor(activeView?.id ?? ''); const exportTitle = exportTitleFor(activeView?.id ?? '');
const canExportFiles = Boolean(activeHtml);
const fallbackShareUrl = canExportFiles && typeof window !== 'undefined'
? window.location.href
: '';
const hasExplicitShareUrl = shareTarget && 'url' in shareTarget;
const explicitShareUrl = typeof shareTarget?.url === 'string' ? shareTarget.url : '';
const previewShareTitle = shareTarget?.title || exportTitle || title;
const previewShareUrl = hasExplicitShareUrl ? explicitShareUrl : fallbackShareUrl;
const previewShareText = t('preview.shareTextDefault', { title: previewShareTitle });
const previewShareCopy = previewShareUrl
? `${previewShareText}\n${previewShareUrl}`
: previewShareText;
const previewShareUrlDisplay = previewShareUrl
.replace(/^https?:\/\//, '')
.replace(/\/$/, '');
const socialShareTargets = useMemo(
() => SOCIAL_SHARE_PLATFORMS.map((item) => ({
...item,
href: item.mode === 'intent' && previewShareUrl
? buildSocialShareUrl(item.platform, {
url: previewShareUrl,
title: previewShareText,
text: previewShareText,
})
: item.entryUrl ?? '',
})),
[previewShareText, previewShareUrl],
);
// Only down-scale: when the stage is wider than the design viewport we // Only down-scale: when the stage is wider than the design viewport we
// render the iframe at native size instead of upscaling pixels. // render the iframe at native size instead of upscaling pixels.
@ -319,7 +436,30 @@ export function PreviewModal({
setFullscreen(false); setFullscreen(false);
} }
async function copyPreviewShare(text: string, key: string): Promise<boolean> {
if (!text) return false;
const ok = await copyToClipboard(text);
setCopyShareFeedback({ key, ok });
window.setTimeout(() => {
setCopyShareFeedback((current) => (
current?.key === key ? null : current
));
}, 1600);
return ok;
}
function openShareDestination(url: string, pendingWindow?: Window | null) {
if (pendingWindow) {
pendingWindow.opener = null;
pendingWindow.location.href = url;
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
}
const showTabs = views.length > 1; const showTabs = views.length > 1;
const showTemplateShareMenu = !isCustomView || Boolean(shareTarget?.url);
const canOpenTemplateShareMenu = canExportFiles || Boolean(previewShareUrl);
return ( return (
<div className="ds-modal-backdrop" role="dialog" aria-modal="true" aria-label={`${title} preview`}> <div className="ds-modal-backdrop" role="dialog" aria-modal="true" aria-label={`${title} preview`}>
@ -406,81 +546,218 @@ export function PreviewModal({
> >
{fullscreen ? t('preview.exit') : t('preview.fullscreen')} {fullscreen ? t('preview.exit') : t('preview.fullscreen')}
</button> </button>
{isCustomView ? null : ( {showTemplateShareMenu ? (
<div className="share-menu" ref={shareRef}> <div className="share-menu template-share-menu" ref={templateShareRef}>
<button <button
className="ghost" className="ghost template-share-trigger"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={shareOpen} aria-expanded={templateShareOpen}
onClick={() => { onClick={() => {
onShareClick?.(); onShareClick?.();
setShareOpen((v) => !v); setTemplateShareOpen((v) => !v);
}} }}
disabled={!activeHtml} disabled={!canOpenTemplateShareMenu}
> >
{t('preview.shareMenu')} <Icon name="share" size={12} />
<span>{t('preview.shareMenu')}</span>
<Icon name="chevron-down" size={12} />
</button> </button>
{shareOpen ? ( {templateShareOpen ? (
<div className="share-menu-popover" role="menu"> <div className="share-menu-popover template-share-popover" role="menu">
<button <div className="template-share-summary">
type="button" <span className="template-share-summary__eyebrow">
className="share-menu-item" {t('preview.shareTemplateBadge')}
role="menuitem" </span>
onClick={() => { <strong>{previewShareTitle}</strong>
onSharePopoverItemClick?.('pdf'); {previewShareUrlDisplay ? (
setShareOpen(false); <span>{previewShareUrlDisplay}</span>
if (activeHtml) ) : null}
exportAsPdf(activeHtml, exportTitle, { deck: activeDeck }); </div>
}} {previewShareUrl ? (
> <>
<span className="share-menu-icon">📄</span> <section className="template-share-section">
<span>{t('common.exportPdf')}</span> <div className="template-share-section__label">
</button> {t('preview.shareSocialGroup')}
<div className="share-menu-divider" /> </div>
<button <div className="template-share-platform-grid">
type="button" {socialShareTargets.map((item) => (
className="share-menu-item" <a
role="menuitem" key={item.platform}
onClick={() => { className={`template-share-platform template-share-platform--${item.platform}`}
onSharePopoverItemClick?.('zip'); role="menuitem"
setShareOpen(false); href={item.href || undefined}
if (activeHtml) exportAsZip(activeHtml, exportTitle); target={item.href ? '_blank' : undefined}
}} rel={item.href ? 'noreferrer noopener' : undefined}
> aria-disabled={item.href ? undefined : 'true'}
<span className="share-menu-icon">🗜</span> tabIndex={item.href ? undefined : -1}
<span>{t('common.exportZip')}</span> onClick={(event) => {
</button> if (!item.href) {
<button event.preventDefault();
type="button" return;
className="share-menu-item" }
role="menuitem" if (item.mode === 'copy-open') {
onClick={() => { event.preventDefault();
onSharePopoverItemClick?.('html'); const shareWindow = window.open('about:blank', '_blank');
setShareOpen(false); const feedbackKey = `social-${item.platform}`;
if (activeHtml) exportAsHtml(activeHtml, exportTitle); void copyPreviewShare(previewShareCopy, feedbackKey).then((ok) => {
}} if (!ok || !item.href) {
> shareWindow?.close();
<span className="share-menu-icon">🌐</span> return;
<span>{t('common.exportHtml')}</span> }
</button> setTemplateShareOpen(false);
<div className="share-menu-divider" /> openShareDestination(item.href, shareWindow);
<button });
type="button" return;
className="share-menu-item" }
role="menuitem" setTemplateShareOpen(false);
onClick={() => { }}
onSharePopoverItemClick?.('open_in_new_tab'); >
setShareOpen(false); <span className="template-share-platform__mark">
openInNewTab(); {item.mark}
}} </span>
> <span>
<span className="share-menu-icon"></span> {copyShareFeedback?.key === `social-${item.platform}`
<span>{t('preview.openInNewTab')}</span> ? copyShareFeedback.ok
</button> ? t('preview.shareCopied')
: t('preview.shareCopyFailed')
: t(item.labelKey)}
</span>
</a>
))}
</div>
</section>
<section className="template-share-section">
<div className="template-share-section__label">
{t('preview.shareCopyGroup')}
</div>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => copyPreviewShare(previewShareUrl, 'link')}
>
<span className="share-menu-icon">
<Icon
name={
copyShareFeedback?.key === 'link'
? copyShareFeedback.ok
? 'check'
: 'close'
: 'link'
}
size={14}
/>
</span>
<span>
{copyShareFeedback?.key === 'link'
? copyShareFeedback.ok
? t('preview.shareCopied')
: t('preview.shareCopyFailed')
: t('preview.copyTemplateLink')}
</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => copyPreviewShare(previewShareCopy, 'text')}
>
<span className="share-menu-icon">
<Icon
name={
copyShareFeedback?.key === 'text'
? copyShareFeedback.ok
? 'check'
: 'close'
: 'copy'
}
size={14}
/>
</span>
<span>
{copyShareFeedback?.key === 'text'
? copyShareFeedback.ok
? t('preview.shareCopied')
: t('preview.shareCopyFailed')
: t('preview.copyShareText')}
</span>
</button>
</section>
</>
) : null}
{canExportFiles ? (
<section className="template-share-section">
<div className="template-share-section__label">
{t('preview.shareExportGroup')}
</div>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
onSharePopoverItemClick?.('pdf');
setTemplateShareOpen(false);
if (activeHtml) {
exportAsPdf(activeHtml, exportTitle, { deck: activeDeck });
}
}}
>
<span className="share-menu-icon">
<Icon name="file" size={14} />
</span>
<span>{t('common.exportPdf')}</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
onSharePopoverItemClick?.('zip');
setTemplateShareOpen(false);
if (activeHtml) exportAsZip(activeHtml, exportTitle);
}}
>
<span className="share-menu-icon">
<Icon name="download" size={14} />
</span>
<span>{t('common.exportZip')}</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
onSharePopoverItemClick?.('html');
setTemplateShareOpen(false);
if (activeHtml) exportAsHtml(activeHtml, exportTitle);
}}
>
<span className="share-menu-icon">
<Icon name="file-code" size={14} />
</span>
<span>{t('common.exportHtml')}</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
onSharePopoverItemClick?.('open_in_new_tab');
setTemplateShareOpen(false);
openInNewTab();
}}
>
<span className="share-menu-icon">
<Icon name="external-link" size={14} />
</span>
<span>{t('preview.openInNewTab')}</span>
</button>
</section>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
)} ) : null}
{headerExtras} {headerExtras}
</div> </div>
</div> </div>

View file

@ -23,7 +23,7 @@ import {
} from '../../providers/registry'; } from '../../providers/registry';
import { DesignSpecView } from '../DesignSpecView'; import { DesignSpecView } from '../DesignSpecView';
import { PreviewModal, type PreviewView } from '../PreviewModal'; import { PreviewModal, type PreviewView } from '../PreviewModal';
import { PluginShareMenu } from './PluginShareMenu'; import { buildPluginShareUrl, PluginShareMenu } from './PluginShareMenu';
import { PluginMetaSections } from './PluginMetaSections'; import { PluginMetaSections } from './PluginMetaSections';
interface Props { interface Props {
@ -131,6 +131,11 @@ export function PluginDesignSystemDetail({
initialViewId={dsRef ? 'showcase' : 'spec'} initialViewId={dsRef ? 'showcase' : 'spec'}
onView={handleView} onView={handleView}
exportTitleFor={(viewId) => `${record.title}${viewId}`} exportTitleFor={(viewId) => `${record.title}${viewId}`}
shareTarget={{
title: record.title,
description: record.manifest?.description || dsRef || undefined,
url: buildPluginShareUrl(record),
}}
onClose={onClose} onClose={onClose}
sidebar={{ sidebar={{
label: 'Plugin info', label: 'Plugin info',

View file

@ -1,8 +1,8 @@
// HTML-preview detail surface for plugins that ship a runnable // HTML-preview detail surface for plugins that ship a runnable
// `od.preview` entry or example output (the same surface ExamplesTab // `od.preview` entry or example output (the same surface ExamplesTab
// uses for skill cards). Wraps the shared PreviewModal so the user // uses for skill cards). Wraps the shared PreviewModal so the user
// gets the full chrome — sandboxed iframe, Fullscreen, Share menu // gets the full chrome — sandboxed iframe, Fullscreen, merged Share menu
// (Export PDF / HTML / Zip / Open in new tab) — plus a primary // plus a primary
// "Use plugin" action that routes through the home applyPlugin flow. // "Use plugin" action that routes through the home applyPlugin flow.
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
@ -14,7 +14,7 @@ import {
type SkillExampleResult, type SkillExampleResult,
} from '../../providers/registry'; } from '../../providers/registry';
import { PreviewModal } from '../PreviewModal'; import { PreviewModal } from '../PreviewModal';
import { PluginShareMenu } from './PluginShareMenu'; import { buildPluginShareUrl, PluginShareMenu } from './PluginShareMenu';
import { PluginMetaSections } from './PluginMetaSections'; import { PluginMetaSections } from './PluginMetaSections';
interface Props { interface Props {
@ -101,6 +101,11 @@ export function PluginExampleDetail({
]} ]}
onView={onView} onView={onView}
exportTitleFor={() => record.title} exportTitleFor={() => record.title}
shareTarget={{
title: record.title,
description: description || undefined,
url: buildPluginShareUrl(record),
}}
onClose={onClose} onClose={onClose}
sidebar={{ sidebar={{
// Surface every plugin-common manifest field — workflow, context // Surface every plugin-common manifest field — workflow, context

View file

@ -3,7 +3,7 @@
// Visually this variant now matches the html-example and design-system // Visually this variant now matches the html-example and design-system
// modals — it reuses PreviewModal so every plugin variant shares the // modals — it reuses PreviewModal so every plugin variant shares the
// same chrome (title + subtitle, primary `Use plugin` CTA, sidebar // same chrome (title + subtitle, primary `Use plugin` CTA, sidebar
// toggle, fullscreen, share menu, close). The stage hosts the // toggle, fullscreen, plugin actions, close). The stage hosts the
// type-specific media (image / video / audio) via PreviewModal's // type-specific media (image / video / audio) via PreviewModal's
// `custom` view kind, and the right-side sidebar carries the prompt // `custom` view kind, and the right-side sidebar carries the prompt
// body + PluginMetaSections so users can read the prompt and inspect // body + PluginMetaSections so users can read the prompt and inspect
@ -19,7 +19,7 @@ import { resolvePluginQueryFallback } from '../../state/projects';
import { Icon } from '../Icon'; import { Icon } from '../Icon';
import { PreviewModal, type PreviewView } from '../PreviewModal'; import { PreviewModal, type PreviewView } from '../PreviewModal';
import { PluginMetaSections } from './PluginMetaSections'; import { PluginMetaSections } from './PluginMetaSections';
import { PluginShareMenu } from './PluginShareMenu'; import { buildPluginShareUrl, PluginShareMenu } from './PluginShareMenu';
interface Props { interface Props {
record: InstalledPluginRecord; record: InstalledPluginRecord;
@ -210,6 +210,11 @@ export function PluginMediaDetail({
subtitle={description || undefined} subtitle={description || undefined}
views={views} views={views}
exportTitleFor={() => record.title} exportTitleFor={() => record.title}
shareTarget={{
title: record.title,
description: description || undefined,
url: buildPluginShareUrl(record),
}}
onClose={onClose} onClose={onClose}
sidebar={{ sidebar={{
label: 'Plugin info', label: 'Plugin info',

View file

@ -1,26 +1,30 @@
// Share affordance for the plugin detail modal. // Plugin-specific detail actions.
// //
// Surfaces the small set of actions a user actually wants when // Surfaces the small set of actions a user wants when they need to install,
// they want to spread / install / link to a plugin they like: // identify, audit, or embed a plugin:
// //
// - Copy plugin id (raw `<id>` for paste-into-yaml) // - Copy plugin id (raw `<id>` for paste-into-yaml)
// - Copy install command (`od plugin install <ref>`) // - Copy install command (`od plugin install <ref>`)
// - Copy share link (link to the marketplace detail page) // - Copy README badge (Open Design powered, includes link)
// - Copy markdown badge (Open Design powered, includes link)
// - Open source on GitHub (when the source is a github repo) // - Open source on GitHub (when the source is a github repo)
// - Open homepage (when manifest.homepage is set) // - Open homepage (when manifest.homepage is set)
// - Open in marketplace (always — the canonical detail page) // - Open in marketplace (always — the canonical detail page)
// //
// We render the popover next to the close button in every detail // We render the popover next to the template Share control in every
// variant header so the affordance reads consistently no matter // detail variant header so plugin-specific actions stay available without
// which preview surface is active. A tiny inline toast confirms // competing with the user's primary "share this template" intent. A tiny inline
// every copy action so the user trusts the click landed. // toast confirms every copy action so the user trusts the click landed.
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { InstalledPluginRecord } from '@open-design/contracts'; import type { InstalledPluginRecord } from '@open-design/contracts';
import { Icon } from '../Icon'; import { Icon } from '../Icon';
import { useT } from '../../i18n';
import { copyToClipboard } from '../../lib/copy-to-clipboard';
import { derivePluginSourceLinks } from '../../runtime/plugin-source'; import { derivePluginSourceLinks } from '../../runtime/plugin-source';
const PUBLIC_PLUGIN_MARKETPLACE_URL = 'https://open-design.ai/plugins';
const PUBLIC_OPEN_DESIGN_MARKETPLACE_ID = 'official';
interface Props { interface Props {
record: InstalledPluginRecord; record: InstalledPluginRecord;
/** /**
@ -37,7 +41,6 @@ interface ShareItem {
label: string; label: string;
icon: icon:
| 'copy' | 'copy'
| 'link'
| 'github' | 'github'
| 'external-link' | 'external-link'
| 'eye'; | 'eye';
@ -72,28 +75,38 @@ function buildInstallCommand(record: InstalledPluginRecord): string {
return `od plugin install ${record.source}`; return `od plugin install ${record.source}`;
} }
function buildShareUrl(record: InstalledPluginRecord): string { export function buildPluginShareUrl(record: InstalledPluginRecord): string | null {
// Browser-side the marketplace detail page is always at if (
// /marketplace/<id>. We use window.location.origin so the record.sourceMarketplaceId !== PUBLIC_OPEN_DESIGN_MARKETPLACE_ID ||
// copied link is a fully qualified URL the recipient can open typeof record.sourceMarketplaceEntryName !== 'string' ||
// in a different session / tab without context. record.sourceMarketplaceEntryName.trim().length === 0
if (typeof window === 'undefined') { ) {
return `/marketplace/${encodeURIComponent(record.id)}`; return null;
} }
return `${window.location.origin}/marketplace/${encodeURIComponent(record.id)}`; // Share surfaces must produce recipient-openable links, not local
// tools-dev origins such as 127.0.0.1:<port>.
return `${PUBLIC_PLUGIN_MARKETPLACE_URL}/${encodeURIComponent(record.id)}`;
} }
function buildMarkdownBadge(record: InstalledPluginRecord): string { function buildPluginMarketplacePath(record: InstalledPluginRecord): string {
const url = buildShareUrl(record); return `/marketplace/${encodeURIComponent(record.id)}`;
}
function buildMarkdownBadge(record: InstalledPluginRecord, url: string): string {
return `[![${record.title} — Open Design plugin](https://img.shields.io/badge/Open%20Design-${encodeURIComponent(record.title)}-d65a31?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2C)](${url})`; return `[![${record.title} — Open Design plugin](https://img.shields.io/badge/Open%20Design-${encodeURIComponent(record.title)}-d65a31?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2C)](${url})`;
} }
export function PluginShareMenu({ record, variant = 'default' }: Props) { export function PluginShareMenu({ record, variant = 'default' }: Props) {
const t = useT();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [copiedKey, setCopiedKey] = useState<string | null>(null); const [copyFeedback, setCopyFeedback] = useState<{
key: string;
ok: boolean;
} | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null); const wrapRef = useRef<HTMLDivElement | null>(null);
const links = derivePluginSourceLinks(record); const links = derivePluginSourceLinks(record);
const publicShareUrl = buildPluginShareUrl(record);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -112,46 +125,45 @@ export function PluginShareMenu({ record, variant = 'default' }: Props) {
}; };
}, [open]); }, [open]);
function copyToClipboard(text: string, key: string) { async function copyPluginShareText(text: string, key: string) {
if (!text) return; if (!text) return;
void navigator.clipboard.writeText(text).then(() => { const ok = await copyToClipboard(text);
setCopiedKey(key); setCopyFeedback({ key, ok });
window.setTimeout(() => { window.setTimeout(() => {
setCopiedKey((current) => (current === key ? null : current)); setCopyFeedback((current) => (
}, 1400); current?.key === key ? null : current
}); ));
}, 1600);
} }
const items: ShareItem[] = [ const items: ShareItem[] = [
{ {
key: 'install', key: 'install',
label: 'Copy install command', label: t('plugins.actions.copyInstallCommand'),
icon: 'copy', icon: 'copy',
copies: true, copies: true,
onSelect: () => copyToClipboard(buildInstallCommand(record), 'install'), onSelect: () => copyPluginShareText(buildInstallCommand(record), 'install'),
}, },
{ {
key: 'id', key: 'id',
label: 'Copy plugin ID', label: t('plugins.actions.copyPluginId'),
icon: 'copy', icon: 'copy',
copies: true, copies: true,
onSelect: () => copyToClipboard(record.id, 'id'), onSelect: () => copyPluginShareText(record.id, 'id'),
},
{
key: 'link',
label: 'Copy share link',
icon: 'link',
copies: true,
onSelect: () => copyToClipboard(buildShareUrl(record), 'link'),
},
{
key: 'badge',
label: 'Copy markdown badge',
icon: 'copy',
copies: true,
onSelect: () => copyToClipboard(buildMarkdownBadge(record), 'badge'),
}, },
]; ];
if (publicShareUrl) {
items.push({
key: 'badge',
label: t('plugins.actions.copyReadmeBadge'),
icon: 'copy',
copies: true,
onSelect: () => copyPluginShareText(
buildMarkdownBadge(record, publicShareUrl),
'badge',
),
});
}
// Open-in-tab actions are real anchors so users can right-click, // Open-in-tab actions are real anchors so users can right-click,
// copy the link address, or open in a new tab from browser chrome. // copy the link address, or open in a new tab from browser chrome.
@ -161,8 +173,8 @@ export function PluginShareMenu({ record, variant = 'default' }: Props) {
key: 'source', key: 'source',
label: label:
record.sourceKind === 'github' || links.sourceUrl.includes('github.com/') record.sourceKind === 'github' || links.sourceUrl.includes('github.com/')
? 'Open source on GitHub' ? t('plugins.actions.openSourceGithub')
: 'Open source', : t('plugins.actions.openSource'),
icon: links.sourceUrl.includes('github.com/') ? 'github' : 'external-link', icon: links.sourceUrl.includes('github.com/') ? 'github' : 'external-link',
href: links.sourceUrl, href: links.sourceUrl,
}); });
@ -170,16 +182,16 @@ export function PluginShareMenu({ record, variant = 'default' }: Props) {
if (links.homepageUrl) { if (links.homepageUrl) {
openItems.push({ openItems.push({
key: 'homepage', key: 'homepage',
label: 'Open homepage', label: t('plugins.actions.openHomepage'),
icon: 'external-link', icon: 'external-link',
href: links.homepageUrl, href: links.homepageUrl,
}); });
} }
openItems.push({ openItems.push({
key: 'marketplace', key: 'marketplace',
label: 'Open in marketplace', label: t('plugins.actions.openMarketplace'),
icon: 'eye', icon: 'eye',
href: buildShareUrl(record), href: buildPluginMarketplacePath(record),
}); });
const triggerClass = const triggerClass =
@ -199,10 +211,10 @@ export function PluginShareMenu({ record, variant = 'default' }: Props) {
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={open} aria-expanded={open}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
title="Share plugin" title={t('designs.menuMore')}
> >
<Icon name="share" size={12} /> <Icon name="more-horizontal" size={12} />
<span>Share</span> <span>{t('homeHero.moreShortcuts')}</span>
</button> </button>
{open ? ( {open ? (
<div className="plugin-share-popover" role="menu"> <div className="plugin-share-popover" role="menu">
@ -216,11 +228,21 @@ export function PluginShareMenu({ record, variant = 'default' }: Props) {
onClick={() => void item.onSelect()} onClick={() => void item.onSelect()}
> >
<Icon <Icon
name={copiedKey === item.key ? 'check' : item.icon} name={
copyFeedback?.key === item.key
? copyFeedback.ok
? 'check'
: 'close'
: item.icon
}
size={12} size={12}
/> />
<span> <span>
{copiedKey === item.key ? 'Copied' : item.label} {copyFeedback?.key === item.key
? copyFeedback.ok
? t('preview.shareCopied')
: t('preview.shareCopyFailed')
: item.label}
</span> </span>
</button> </button>
))} ))}

View file

@ -3,6 +3,13 @@ import type { Dict } from '../types';
export const ar: Dict = { export const ar: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'نسخ أمر التثبيت',
'plugins.actions.copyPluginId': 'نسخ معرّف الإضافة',
'plugins.actions.copyReadmeBadge': 'نسخ شارة README',
'plugins.actions.openSourceGithub': 'فتح المصدر على GitHub',
'plugins.actions.openSource': 'فتح المصدر',
'plugins.actions.openHomepage': 'فتح الصفحة الرئيسية',
'plugins.actions.openMarketplace': 'فتح في السوق',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -831,7 +838,23 @@ export const ar: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'تقرير سنوي تفاعلي لمنظمة مناخية غير ربحية - تخطيط تحريري بالتمرير الطويل يمزج بين كتل الاقتباسات الكبيرة، تصورات البيانات (أعمدة متراكمة، عدادات متحركة، خريطة لمواقع المشاريع)، فواصل صور، جدار المتبرعين، ودعوة نهائية للعمل. خط متن حديث، تسميات مخططات sans-serif، لوحة ألوان ورقية ترابية.', 'تقرير سنوي تفاعلي لمنظمة مناخية غير ربحية - تخطيط تحريري بالتمرير الطويل يمزج بين كتل الاقتباسات الكبيرة، تصورات البيانات (أعمدة متراكمة، عدادات متحركة، خريطة لمواقع المشاريع)، فواصل صور، جدار المتبرعين، ودعوة نهائية للعمل. خط متن حديث، تسميات مخططات sans-serif، لوحة ألوان ورقية ترابية.',
'preview.shareMenu': 'مشاركة ▾', 'preview.shareMenu': 'مشاركة',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'فتح في علامة تبويب جديدة', 'preview.openInNewTab': 'فتح في علامة تبويب جديدة',
'preview.exit': '⤓ خروج', 'preview.exit': '⤓ خروج',
'preview.fullscreen': '⤢ ملء الشاشة', 'preview.fullscreen': '⤢ ملء الشاشة',

View file

@ -3,6 +3,13 @@ import { en } from './en';
export const de: Dict = { export const de: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'Installationsbefehl kopieren',
'plugins.actions.copyPluginId': 'Plugin-ID kopieren',
'plugins.actions.copyReadmeBadge': 'README-Badge kopieren',
'plugins.actions.openSourceGithub': 'Quelle auf GitHub öffnen',
'plugins.actions.openSource': 'Quelle öffnen',
'plugins.actions.openHomepage': 'Homepage öffnen',
'plugins.actions.openMarketplace': 'Im Marktplatz öffnen',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -719,7 +726,23 @@ export const de: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'Ein interaktiver Annual Report für eine Klima-Non-Profit-Organisation — Long-Scroll-Editorial-Layout mit großen Pull-Quote-Blöcken, Datenvisualisierungen (gestapelte Balken, animierte Counter, Choropleth-Karte der Projektstandorte), Fotobrechern, Spenderwand und finalem Call-to-Action. Moderne Serifenschrift für Body, Sans-Serif-Chartlabels, erdige Papierpalette.', 'Ein interaktiver Annual Report für eine Klima-Non-Profit-Organisation — Long-Scroll-Editorial-Layout mit großen Pull-Quote-Blöcken, Datenvisualisierungen (gestapelte Balken, animierte Counter, Choropleth-Karte der Projektstandorte), Fotobrechern, Spenderwand und finalem Call-to-Action. Moderne Serifenschrift für Body, Sans-Serif-Chartlabels, erdige Papierpalette.',
'preview.shareMenu': 'Teilen ▾', 'preview.shareMenu': 'Teilen',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'In neuem Tab öffnen', 'preview.openInNewTab': 'In neuem Tab öffnen',
'preview.exit': '⤓ Beenden', 'preview.exit': '⤓ Beenden',
'preview.fullscreen': '⤢ Vollbild', 'preview.fullscreen': '⤢ Vollbild',

View file

@ -57,6 +57,13 @@ export const en: Dict = {
'plugins.availableDetails.integrity': 'Integrity', 'plugins.availableDetails.integrity': 'Integrity',
'plugins.availableDetails.permissions': 'Permissions', 'plugins.availableDetails.permissions': 'Permissions',
'plugins.availableDetails.capabilitySummary': 'Capability summary', 'plugins.availableDetails.capabilitySummary': 'Capability summary',
'plugins.actions.copyInstallCommand': 'Copy install command',
'plugins.actions.copyPluginId': 'Copy plugin ID',
'plugins.actions.copyReadmeBadge': 'Copy README badge',
'plugins.actions.openSourceGithub': 'Open source on GitHub',
'plugins.actions.openSource': 'Open source',
'plugins.actions.openHomepage': 'Open homepage',
'plugins.actions.openMarketplace': 'Open in marketplace',
'app.brand': 'Open Design', 'app.brand': 'Open Design',
'app.brandPill': 'Research Preview', 'app.brandPill': 'Research Preview',
@ -1427,7 +1434,23 @@ export const en: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'An interactive annual report for a climate non-profit — long-scroll editorial layout mixing big pull-quote blocks, data visualizations (stacked bars, animated counters, a choropleth map of project sites), photography breakers, donor wall, and a final call-to-action. Modern serif body, sans-serif chart labels, earthy paper palette.', 'An interactive annual report for a climate non-profit — long-scroll editorial layout mixing big pull-quote blocks, data visualizations (stacked bars, animated counters, a choropleth map of project sites), photography breakers, donor wall, and a final call-to-action. Modern serif body, sans-serif chart labels, earthy paper palette.',
'preview.shareMenu': 'Share ▾', 'preview.shareMenu': 'Share',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'Open in new tab', 'preview.openInNewTab': 'Open in new tab',
'preview.exit': '⤓ Exit', 'preview.exit': '⤓ Exit',
'preview.fullscreen': '⤢ Fullscreen', 'preview.fullscreen': '⤢ Fullscreen',

View file

@ -3,6 +3,13 @@ import { en } from './en';
export const esES: Dict = { export const esES: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'Copiar comando de instalación',
'plugins.actions.copyPluginId': 'Copiar ID del plugin',
'plugins.actions.copyReadmeBadge': 'Copiar insignia README',
'plugins.actions.openSourceGithub': 'Abrir código fuente en GitHub',
'plugins.actions.openSource': 'Abrir código fuente',
'plugins.actions.openHomepage': 'Abrir página de inicio',
'plugins.actions.openMarketplace': 'Abrir en el marketplace',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -720,7 +727,23 @@ export const esES: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'Una memoria anual interactiva para una ONG climática: maquetación editorial long-scroll mezclando grandes bloques de citas, visualizaciones de datos (barras apiladas, contadores animados, un mapa coroplético de proyectos), separadores con fotografía, muro de donantes y llamada a la acción final. Cuerpo en serif moderna, etiquetas de gráficos en sans-serif, paleta terrosa de papel.', 'Una memoria anual interactiva para una ONG climática: maquetación editorial long-scroll mezclando grandes bloques de citas, visualizaciones de datos (barras apiladas, contadores animados, un mapa coroplético de proyectos), separadores con fotografía, muro de donantes y llamada a la acción final. Cuerpo en serif moderna, etiquetas de gráficos en sans-serif, paleta terrosa de papel.',
'preview.shareMenu': 'Compartir ▾', 'preview.shareMenu': 'Compartir',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'Abrir en pestaña nueva', 'preview.openInNewTab': 'Abrir en pestaña nueva',
'preview.exit': '⤓ Salir', 'preview.exit': '⤓ Salir',
'preview.fullscreen': '⤢ Pantalla completa', 'preview.fullscreen': '⤢ Pantalla completa',

View file

@ -3,6 +3,13 @@ import { en } from './en';
export const fa: Dict = { export const fa: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'کپی دستور نصب',
'plugins.actions.copyPluginId': 'کپی شناسهٔ افزونه',
'plugins.actions.copyReadmeBadge': 'کپی نشان README',
'plugins.actions.openSourceGithub': 'باز کردن منبع در GitHub',
'plugins.actions.openSource': 'باز کردن منبع',
'plugins.actions.openHomepage': 'باز کردن صفحهٔ اصلی',
'plugins.actions.openMarketplace': 'باز کردن در بازارچه',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -853,7 +860,23 @@ export const fa: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'یک گزارش سالانه تعاملی برای یک سازمان غیرانتفاعی آب و هوایی — چیدمان سردبیری اسکرول بلند که بلوک‌های نقل قول بزرگ، تصویرسازی‌های داده، عکاسی، دیوار اهداکنندگان و یک فراخوان به عمل نهایی را ترکیب می‌کند.', 'یک گزارش سالانه تعاملی برای یک سازمان غیرانتفاعی آب و هوایی — چیدمان سردبیری اسکرول بلند که بلوک‌های نقل قول بزرگ، تصویرسازی‌های داده، عکاسی، دیوار اهداکنندگان و یک فراخوان به عمل نهایی را ترکیب می‌کند.',
'preview.shareMenu': 'اشتراک‌گذاری ▾', 'preview.shareMenu': 'اشتراک‌گذاری',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'باز کردن در تب جدید', 'preview.openInNewTab': 'باز کردن در تب جدید',
'preview.exit': '⤓ خروج', 'preview.exit': '⤓ خروج',
'preview.fullscreen': '⤢ تمام صفحه', 'preview.fullscreen': '⤢ تمام صفحه',

View file

@ -3,6 +3,13 @@ import type { Dict } from '../types';
export const fr: Dict = { export const fr: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'Copier la commande dinstallation',
'plugins.actions.copyPluginId': 'Copier lID du plugin',
'plugins.actions.copyReadmeBadge': 'Copier le badge README',
'plugins.actions.openSourceGithub': 'Ouvrir la source sur GitHub',
'plugins.actions.openSource': 'Ouvrir la source',
'plugins.actions.openHomepage': 'Ouvrir la page daccueil',
'plugins.actions.openMarketplace': 'Ouvrir dans la marketplace',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -847,7 +854,23 @@ export const fr: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'Un rapport annuel interactif pour une ONG climatique — mise en page éditoriale à long défilement mêlant de grands blocs de citation, des visualisations de données (barres empilées, compteurs animés, une carte choroplèthe des sites de projet), des séparateurs photo, un mur de donateurs et un appel à l\'action final. Texte en serif moderne, étiquettes de graphiques en sans-serif, palette papier aux tons terreux.', 'Un rapport annuel interactif pour une ONG climatique — mise en page éditoriale à long défilement mêlant de grands blocs de citation, des visualisations de données (barres empilées, compteurs animés, une carte choroplèthe des sites de projet), des séparateurs photo, un mur de donateurs et un appel à l\'action final. Texte en serif moderne, étiquettes de graphiques en sans-serif, palette papier aux tons terreux.',
'preview.shareMenu': 'Partager ▾', 'preview.shareMenu': 'Partager',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'Ouvrir dans un nouvel onglet', 'preview.openInNewTab': 'Ouvrir dans un nouvel onglet',
'preview.exit': '⤓ Quitter', 'preview.exit': '⤓ Quitter',
'preview.fullscreen': '⤢ Plein écran', 'preview.fullscreen': '⤢ Plein écran',

View file

@ -3,6 +3,13 @@ import type { Dict } from '../types';
export const hu: Dict = { export const hu: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'Telepítési parancs másolása',
'plugins.actions.copyPluginId': 'Pluginazonosító másolása',
'plugins.actions.copyReadmeBadge': 'README jelvény másolása',
'plugins.actions.openSourceGithub': 'Forrás megnyitása GitHubon',
'plugins.actions.openSource': 'Forrás megnyitása',
'plugins.actions.openHomepage': 'Honlap megnyitása',
'plugins.actions.openMarketplace': 'Megnyitás a piactéren',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -831,7 +838,23 @@ export const hu: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'Interaktív éves jelentés egy klíma-non-profitnak — long-scroll magazinszerű elrendezés, nagy kiemelt-idézet blokkokkal, adatvizualizációkkal (egymásra rakott oszlopok, animált számlálók, projekthelyek choropleth térképe), fotó-megszakítókkal, donor fallal, és záró cselekvésre hívással. Modern serif törzs, sans-serif diagramcímkék, földes papírpaletta.', 'Interaktív éves jelentés egy klíma-non-profitnak — long-scroll magazinszerű elrendezés, nagy kiemelt-idézet blokkokkal, adatvizualizációkkal (egymásra rakott oszlopok, animált számlálók, projekthelyek choropleth térképe), fotó-megszakítókkal, donor fallal, és záró cselekvésre hívással. Modern serif törzs, sans-serif diagramcímkék, földes papírpaletta.',
'preview.shareMenu': 'Megosztás ▾', 'preview.shareMenu': 'Megosztás',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'Megnyitás új lapon', 'preview.openInNewTab': 'Megnyitás új lapon',
'preview.exit': '⤓ Kilépés', 'preview.exit': '⤓ Kilépés',
'preview.fullscreen': '⤢ Teljes képernyő', 'preview.fullscreen': '⤢ Teljes képernyő',

View file

@ -3,6 +3,13 @@ import { en } from './en';
export const id: Dict = { export const id: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'Salin perintah instal',
'plugins.actions.copyPluginId': 'Salin ID plugin',
'plugins.actions.copyReadmeBadge': 'Salin lencana README',
'plugins.actions.openSourceGithub': 'Buka sumber di GitHub',
'plugins.actions.openSource': 'Buka sumber',
'plugins.actions.openHomepage': 'Buka beranda',
'plugins.actions.openMarketplace': 'Buka di marketplace',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -943,6 +950,22 @@ export const id: Dict = {
'chat.example3Prompt': 'Buat laporan tahunan long-scroll dengan hero editorial, angka utama, timeline, dan visualisasi data.', 'chat.example3Prompt': 'Buat laporan tahunan long-scroll dengan hero editorial, angka utama, timeline, dan visualisasi data.',
'preview.shareMenu': 'Bagikan', 'preview.shareMenu': 'Bagikan',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'Buka di tab baru', 'preview.openInNewTab': 'Buka di tab baru',
'preview.exit': 'Keluar', 'preview.exit': 'Keluar',
'preview.fullscreen': 'Layar penuh', 'preview.fullscreen': 'Layar penuh',

View file

@ -3,6 +3,13 @@ import type { Dict } from '../types';
export const it: Dict = { export const it: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'Copia comando di installazione',
'plugins.actions.copyPluginId': 'Copia ID plugin',
'plugins.actions.copyReadmeBadge': 'Copia badge README',
'plugins.actions.openSourceGithub': 'Apri sorgente su GitHub',
'plugins.actions.openSource': 'Apri sorgente',
'plugins.actions.openHomepage': 'Apri homepage',
'plugins.actions.openMarketplace': 'Apri nel marketplace',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -746,7 +753,23 @@ export const it: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'Un report annuale interattivo per un\'ONG climatica — layout editoriale a scorrimento lungo che mescola grandi blocchi di citazioni, visualizzazioni di dati (barre sovrapposte, contatori animati, una mappa coropletica dei siti di progetto), separatori fotografici, un muro di donatori e una chiamata all\'azione finale. Testo in serif moderno, etichette di grafici in sans-serif, palette di carta dai toni terrosi.', 'Un report annuale interattivo per un\'ONG climatica — layout editoriale a scorrimento lungo che mescola grandi blocchi di citazioni, visualizzazioni di dati (barre sovrapposte, contatori animati, una mappa coropletica dei siti di progetto), separatori fotografici, un muro di donatori e una chiamata all\'azione finale. Testo in serif moderno, etichette di grafici in sans-serif, palette di carta dai toni terrosi.',
'preview.shareMenu': 'Condividi ▾', 'preview.shareMenu': 'Condividi',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'Apri in una nuova scheda', 'preview.openInNewTab': 'Apri in una nuova scheda',
'preview.exit': '⤓ Esci', 'preview.exit': '⤓ Esci',
'preview.fullscreen': '⤢ Schermo intero', 'preview.fullscreen': '⤢ Schermo intero',

View file

@ -3,6 +3,13 @@ import { en } from './en';
export const ja: Dict = { export const ja: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'インストールコマンドをコピー',
'plugins.actions.copyPluginId': 'プラグイン ID をコピー',
'plugins.actions.copyReadmeBadge': 'README バッジをコピー',
'plugins.actions.openSourceGithub': 'GitHub でソースを開く',
'plugins.actions.openSource': 'ソースを開く',
'plugins.actions.openHomepage': 'ホームページを開く',
'plugins.actions.openMarketplace': 'マーケットプレイスで開く',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -718,7 +725,23 @@ export const ja: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'気候系 NPO のインタラクティブ年次報告書 — 大きな引用ブロック、データビジュアライゼーション(積み上げ棒グラフ、アニメーションカウンター、プロジェクトサイトのコロプレスマップ)、写真ブレーカー、ドナーウォール、最後にコールトゥアクションを混在させた長スクロール編集レイアウト。モダンなセリフ体本文、サンセリフのチャートラベル、土のようなペーパーパレット。', '気候系 NPO のインタラクティブ年次報告書 — 大きな引用ブロック、データビジュアライゼーション(積み上げ棒グラフ、アニメーションカウンター、プロジェクトサイトのコロプレスマップ)、写真ブレーカー、ドナーウォール、最後にコールトゥアクションを混在させた長スクロール編集レイアウト。モダンなセリフ体本文、サンセリフのチャートラベル、土のようなペーパーパレット。',
'preview.shareMenu': '共有 ▾', 'preview.shareMenu': '共有',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': '新しいタブで開く', 'preview.openInNewTab': '新しいタブで開く',
'preview.exit': '⤓ 終了', 'preview.exit': '⤓ 終了',
'preview.fullscreen': '⤢ フルスクリーン', 'preview.fullscreen': '⤢ フルスクリーン',

View file

@ -3,6 +3,13 @@ import type { Dict } from '../types';
export const ko: Dict = { export const ko: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': '설치 명령 복사',
'plugins.actions.copyPluginId': '플러그인 ID 복사',
'plugins.actions.copyReadmeBadge': 'README 배지 복사',
'plugins.actions.openSourceGithub': 'GitHub에서 소스 열기',
'plugins.actions.openSource': '소스 열기',
'plugins.actions.openHomepage': '홈페이지 열기',
'plugins.actions.openMarketplace': '마켓플레이스에서 열기',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -831,7 +838,23 @@ export const ko: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'기후 변화 관련 비영리 단체의 인터랙티브 연례 보고서를 제작해 주세요. 큰 인용구 블록이 특징인 긴 스크롤 기반 에디토리얼 레이아웃과 함께, 누적 막대 그래프, 움직이는 카운터, 프로젝트 사이트 분포를 보여주는 코로플레스 맵 등 데이터 시각화를 섞어 주세요. 텍스트 중간중간 사진을 삽입하고, 기부자 명단과 마지막 결론 부분(CTA)을 포함해 주세요. 본문은 모던 세리프 폰트를, 차트 라벨은 산세리프 폰트를 사용하며 전체적으로 따뜻한 색감의 팔레트를 적용해 주세요.', '기후 변화 관련 비영리 단체의 인터랙티브 연례 보고서를 제작해 주세요. 큰 인용구 블록이 특징인 긴 스크롤 기반 에디토리얼 레이아웃과 함께, 누적 막대 그래프, 움직이는 카운터, 프로젝트 사이트 분포를 보여주는 코로플레스 맵 등 데이터 시각화를 섞어 주세요. 텍스트 중간중간 사진을 삽입하고, 기부자 명단과 마지막 결론 부분(CTA)을 포함해 주세요. 본문은 모던 세리프 폰트를, 차트 라벨은 산세리프 폰트를 사용하며 전체적으로 따뜻한 색감의 팔레트를 적용해 주세요.',
'preview.shareMenu': '공유 ▾', 'preview.shareMenu': '공유',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': '새 탭에서 열기', 'preview.openInNewTab': '새 탭에서 열기',
'preview.exit': '⤓ 나가기', 'preview.exit': '⤓ 나가기',
'preview.fullscreen': '⤢ 전체 화면', 'preview.fullscreen': '⤢ 전체 화면',

View file

@ -3,6 +3,13 @@ import type { Dict } from '../types';
export const pl: Dict = { export const pl: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'Kopiuj polecenie instalacji',
'plugins.actions.copyPluginId': 'Kopiuj ID wtyczki',
'plugins.actions.copyReadmeBadge': 'Kopiuj odznakę README',
'plugins.actions.openSourceGithub': 'Otwórz źródło na GitHubie',
'plugins.actions.openSource': 'Otwórz źródło',
'plugins.actions.openHomepage': 'Otwórz stronę domową',
'plugins.actions.openMarketplace': 'Otwórz w marketplace',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -831,7 +838,23 @@ export const pl: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'Interaktywny raport roczny dla organizacji klimatycznej — układ typu long-scroll mieszający bloki z dużymi cytatami, wizualizacje danych (wykresy słupkowe, animowane liczniki, mapa choropletowa lokalizacji projektów), przerywniki fotograficzne, lista darczyńców i końcowe wezwanie do działania. Nowoczesny tekst szeryfowy, etykiety wykresów bezszeryfowe, ziemista paleta kolorów.', 'Interaktywny raport roczny dla organizacji klimatycznej — układ typu long-scroll mieszający bloki z dużymi cytatami, wizualizacje danych (wykresy słupkowe, animowane liczniki, mapa choropletowa lokalizacji projektów), przerywniki fotograficzne, lista darczyńców i końcowe wezwanie do działania. Nowoczesny tekst szeryfowy, etykiety wykresów bezszeryfowe, ziemista paleta kolorów.',
'preview.shareMenu': 'Udostępnij ▾', 'preview.shareMenu': 'Udostępnij',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'Otwórz w nowej karcie', 'preview.openInNewTab': 'Otwórz w nowej karcie',
'preview.exit': '⤓ Wyjdź', 'preview.exit': '⤓ Wyjdź',
'preview.fullscreen': '⤢ Pełny ekran', 'preview.fullscreen': '⤢ Pełny ekran',

View file

@ -3,6 +3,13 @@ import { en } from './en';
export const ptBR: Dict = { export const ptBR: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'Copiar comando de instalação',
'plugins.actions.copyPluginId': 'Copiar ID do plugin',
'plugins.actions.copyReadmeBadge': 'Copiar selo do README',
'plugins.actions.openSourceGithub': 'Abrir fonte no GitHub',
'plugins.actions.openSource': 'Abrir fonte',
'plugins.actions.openHomepage': 'Abrir homepage',
'plugins.actions.openMarketplace': 'Abrir no marketplace',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -852,7 +859,23 @@ export const ptBR: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'Um relatório anual interativo para uma ONG climática — layout editorial long-scroll misturando grandes blocos de citações, visualizações de dados (barras empilhadas, contadores animados, mapa coroplético de locais de projetos), quebras com fotografia, mural de doadores e chamada final para ação. Corpo com serif moderna, rótulos de gráficos sem serifa, paleta terrosa de papel.', 'Um relatório anual interativo para uma ONG climática — layout editorial long-scroll misturando grandes blocos de citações, visualizações de dados (barras empilhadas, contadores animados, mapa coroplético de locais de projetos), quebras com fotografia, mural de doadores e chamada final para ação. Corpo com serif moderna, rótulos de gráficos sem serifa, paleta terrosa de papel.',
'preview.shareMenu': 'Compartilhar ▾', 'preview.shareMenu': 'Compartilhar',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'Abrir em nova aba', 'preview.openInNewTab': 'Abrir em nova aba',
'preview.exit': '⤓ Sair', 'preview.exit': '⤓ Sair',
'preview.fullscreen': '⤢ Tela cheia', 'preview.fullscreen': '⤢ Tela cheia',

View file

@ -3,6 +3,13 @@ import { en } from './en';
export const ru: Dict = { export const ru: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'Скопировать команду установки',
'plugins.actions.copyPluginId': 'Скопировать ID плагина',
'plugins.actions.copyReadmeBadge': 'Скопировать бейдж README',
'plugins.actions.openSourceGithub': 'Открыть исходники на GitHub',
'plugins.actions.openSource': 'Открыть исходники',
'plugins.actions.openHomepage': 'Открыть домашнюю страницу',
'plugins.actions.openMarketplace': 'Открыть в маркетплейсе',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -852,7 +859,23 @@ export const ru: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'Интерактивный годовой отчёт для климатической некоммерческой организации — редакционный макет с длинной прокруткой, сочетающий крупные цитатные блоки, визуализации данных (составные столбчатые диаграммы, анимированные счётчики, хороплетную карту проектных площадок), фотографические разделители, стену доноров и финальный призыв к действию. Современный serif для основного текста, sans-serif для подписей графиков и землистая бумажная палитра.', 'Интерактивный годовой отчёт для климатической некоммерческой организации — редакционный макет с длинной прокруткой, сочетающий крупные цитатные блоки, визуализации данных (составные столбчатые диаграммы, анимированные счётчики, хороплетную карту проектных площадок), фотографические разделители, стену доноров и финальный призыв к действию. Современный serif для основного текста, sans-serif для подписей графиков и землистая бумажная палитра.',
'preview.shareMenu': 'Поделиться ▾', 'preview.shareMenu': 'Поделиться',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'Открыть в новой вкладке', 'preview.openInNewTab': 'Открыть в новой вкладке',
'preview.exit': '⤓ Выход', 'preview.exit': '⤓ Выход',
'preview.fullscreen': '⤢ Полноэкранный', 'preview.fullscreen': '⤢ Полноэкранный',

View file

@ -3,6 +3,13 @@ import type { Dict } from '../types';
export const th: Dict = { export const th: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'คัดลอกคำสั่งติดตั้ง',
'plugins.actions.copyPluginId': 'คัดลอก ID ปลั๊กอิน',
'plugins.actions.copyReadmeBadge': 'คัดลอกแบดจ์ README',
'plugins.actions.openSourceGithub': 'เปิดซอร์สบน GitHub',
'plugins.actions.openSource': 'เปิดซอร์ส',
'plugins.actions.openHomepage': 'เปิดหน้าโฮมเพจ',
'plugins.actions.openMarketplace': 'เปิดใน marketplace',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -783,7 +790,23 @@ export const th: Dict = {
'chat.example3Tag': 'บรรณาธิการ', 'chat.example3Tag': 'บรรณาธิการ',
'chat.example3Prompt': 'หน้าจอรายงานผลกิจกรรมประจำปีแบบแสดงข้อความและกราฟ เลื่อนดูได้ยาวๆ', 'chat.example3Prompt': 'หน้าจอรายงานผลกิจกรรมประจำปีแบบแสดงข้อความและกราฟ เลื่อนดูได้ยาวๆ',
'preview.shareMenu': 'แชร์ ▾', 'preview.shareMenu': 'แชร์',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'เปิดในแท็บใหม่', 'preview.openInNewTab': 'เปิดในแท็บใหม่',
'preview.exit': '⤓ ออก', 'preview.exit': '⤓ ออก',
'preview.fullscreen': '⤢ เต็มหน้าจอ', 'preview.fullscreen': '⤢ เต็มหน้าจอ',

View file

@ -3,6 +3,13 @@ import type { Dict } from '../types';
export const tr: Dict = { export const tr: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'Kurulum komutunu kopyala',
'plugins.actions.copyPluginId': 'Eklenti IDsini kopyala',
'plugins.actions.copyReadmeBadge': 'README rozetini kopyala',
'plugins.actions.openSourceGithub': 'Kaynağı GitHubda aç',
'plugins.actions.openSource': 'Kaynağı aç',
'plugins.actions.openHomepage': 'Ana sayfayı aç',
'plugins.actions.openMarketplace': 'Marketplacete aç',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -820,7 +827,23 @@ export const tr: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'İklim odaklı bir sivil toplum kuruluşu için etkileşimli bir yıllık rapor: büyük alıntı blokları, veri görselleştirmeleri (yığılmış çubuk grafikler, animasyonlu sayaçlar, proje sahalarını gösteren choropleth harita), fotoğraf geçişleri, bağışçı duvarı ve finalde bir call-to-action içeren uzun kaydırmalı editoryal yerleşim. Modern serif gövde metni, grafik etiketlerinde sans-serif, toprak tonlarında kâğıt paleti.', 'İklim odaklı bir sivil toplum kuruluşu için etkileşimli bir yıllık rapor: büyük alıntı blokları, veri görselleştirmeleri (yığılmış çubuk grafikler, animasyonlu sayaçlar, proje sahalarını gösteren choropleth harita), fotoğraf geçişleri, bağışçı duvarı ve finalde bir call-to-action içeren uzun kaydırmalı editoryal yerleşim. Modern serif gövde metni, grafik etiketlerinde sans-serif, toprak tonlarında kâğıt paleti.',
'preview.shareMenu': 'Paylaş ▾', 'preview.shareMenu': 'Paylaş',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'Yeni sekmede aç', 'preview.openInNewTab': 'Yeni sekmede aç',
'preview.exit': '⤓ Çık', 'preview.exit': '⤓ Çık',
'preview.fullscreen': '⤢ Tam ekran', 'preview.fullscreen': '⤢ Tam ekran',

View file

@ -3,6 +3,13 @@ import { en } from './en';
export const uk: Dict = { export const uk: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': 'Скопіювати команду встановлення',
'plugins.actions.copyPluginId': 'Скопіювати ID плагіна',
'plugins.actions.copyReadmeBadge': 'Скопіювати бейдж README',
'plugins.actions.openSourceGithub': 'Відкрити код на GitHub',
'plugins.actions.openSource': 'Відкрити код',
'plugins.actions.openHomepage': 'Відкрити домашню сторінку',
'plugins.actions.openMarketplace': 'Відкрити в маркетплейсі',
'workingDirPicker.title': "Folder", 'workingDirPicker.title': "Folder",
'workingDirPicker.homeTitle': "Choose where this project should live", 'workingDirPicker.homeTitle': "Choose where this project should live",
'workingDirPicker.processing': "Processing…", 'workingDirPicker.processing': "Processing…",
@ -853,7 +860,23 @@ export const uk: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'Інтерактивний річний звіт для екологічної організації, що не шукає прибутку — довгий скрол редакційного макета, що змішує великі блоки цитат, візуалізацію даних (накопичені бари, анімовані лічильники, хорографічна карта місць проектів), перерви за фотографіями, стіна донорів та остаточний клич. Сучасний шрифт з засічками, безсерифні ярлики графіків, землиста палітра паперу.', 'Інтерактивний річний звіт для екологічної організації, що не шукає прибутку — довгий скрол редакційного макета, що змішує великі блоки цитат, візуалізацію даних (накопичені бари, анімовані лічильники, хорографічна карта місць проектів), перерви за фотографіями, стіна донорів та остаточний клич. Сучасний шрифт з засічками, безсерифні ярлики графіків, землиста палітра паперу.',
'preview.shareMenu': 'Поділитися ▾', 'preview.shareMenu': 'Поділитися',
'preview.exportMenu': 'Export',
'preview.shareTemplateBadge': 'Template',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': 'Copy template link',
'preview.copyShareText': 'Copy share text',
'preview.shareSocialGroup': 'Share to social',
'preview.shareCopyGroup': 'Copy',
'preview.shareExportGroup': 'Export files',
'preview.shareCopied': 'Copied',
'preview.shareCopyFailed': 'Copy failed',
'preview.shareTextDefault': 'Open Design template: {title}',
'preview.openInNewTab': 'Відкрити в новій вкладці', 'preview.openInNewTab': 'Відкрити в новій вкладці',
'preview.exit': '⤓ Вихід', 'preview.exit': '⤓ Вихід',
'preview.fullscreen': '⤢ Повноекранний режим', 'preview.fullscreen': '⤢ Повноекранний режим',

View file

@ -57,6 +57,13 @@ export const zhCN: Dict = {
'plugins.availableDetails.integrity': '完整性', 'plugins.availableDetails.integrity': '完整性',
'plugins.availableDetails.permissions': '权限', 'plugins.availableDetails.permissions': '权限',
'plugins.availableDetails.capabilitySummary': '能力摘要', 'plugins.availableDetails.capabilitySummary': '能力摘要',
'plugins.actions.copyInstallCommand': '复制安装命令',
'plugins.actions.copyPluginId': '复制插件 ID',
'plugins.actions.copyReadmeBadge': '复制 README 徽章',
'plugins.actions.openSourceGithub': '在 GitHub 打开源码',
'plugins.actions.openSource': '打开源码',
'plugins.actions.openHomepage': '打开项目主页',
'plugins.actions.openMarketplace': '在插件市场打开',
'app.brand': 'Open Design', 'app.brand': 'Open Design',
'app.brandPill': '研究预览版', 'app.brandPill': '研究预览版',
@ -1418,7 +1425,23 @@ export const zhCN: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'为一家关注气候议题的非营利机构制作互动式年度报告 —— 长滚动编辑式布局,混合大段引言区块、数据可视化(堆叠柱状图、动态计数器、项目地点分布的等值线地图)、摄影分隔页、捐赠者墙,以及最终行动号召。现代衬线正文、无衬线图表标签、大地纸张配色。', '为一家关注气候议题的非营利机构制作互动式年度报告 —— 长滚动编辑式布局,混合大段引言区块、数据可视化(堆叠柱状图、动态计数器、项目地点分布的等值线地图)、摄影分隔页、捐赠者墙,以及最终行动号召。现代衬线正文、无衬线图表标签、大地纸张配色。',
'preview.shareMenu': '分享 ▾', 'preview.shareMenu': '分享',
'preview.exportMenu': '导出',
'preview.shareTemplateBadge': '模板',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': '复制模板链接',
'preview.copyShareText': '复制分享文案',
'preview.shareSocialGroup': '分享到社媒',
'preview.shareCopyGroup': '复制',
'preview.shareExportGroup': '导出文件',
'preview.shareCopied': '已复制',
'preview.shareCopyFailed': '复制失败',
'preview.shareTextDefault': 'Open Design 模板:{title}',
'preview.openInNewTab': '在新标签页中打开', 'preview.openInNewTab': '在新标签页中打开',
'preview.exit': '⤓ 退出', 'preview.exit': '⤓ 退出',
'preview.fullscreen': '⤢ 全屏', 'preview.fullscreen': '⤢ 全屏',

View file

@ -3,6 +3,13 @@ import { en } from './en';
export const zhTW: Dict = { export const zhTW: Dict = {
...en, ...en,
'plugins.actions.copyInstallCommand': '複製安裝命令',
'plugins.actions.copyPluginId': '複製外掛 ID',
'plugins.actions.copyReadmeBadge': '複製 README 徽章',
'plugins.actions.openSourceGithub': '在 GitHub 開啟原始碼',
'plugins.actions.openSource': '開啟原始碼',
'plugins.actions.openHomepage': '開啟專案首頁',
'plugins.actions.openMarketplace': '在外掛市場開啟',
'workingDirPicker.title': "目錄", 'workingDirPicker.title': "目錄",
'workingDirPicker.homeTitle': "選擇專案要放在哪個目錄下", 'workingDirPicker.homeTitle': "選擇專案要放在哪個目錄下",
'workingDirPicker.processing': "處理中…", 'workingDirPicker.processing': "處理中…",
@ -1025,7 +1032,23 @@ export const zhTW: Dict = {
'chat.example3Prompt': 'chat.example3Prompt':
'為一家關注氣候議題的非營利機構製作互動式年度報告 —— 長捲動編輯式佈局,混合大段引言區塊、資料視覺化(堆疊柱狀圖、動態計數器、專案地點分布的等值線地圖)、攝影分隔頁、捐贈者牆,以及最終行動號召。現代襯線內文、無襯線圖表標籤、大地紙張配色。', '為一家關注氣候議題的非營利機構製作互動式年度報告 —— 長捲動編輯式佈局,混合大段引言區塊、資料視覺化(堆疊柱狀圖、動態計數器、專案地點分布的等值線地圖)、攝影分隔頁、捐贈者牆,以及最終行動號召。現代襯線內文、無襯線圖表標籤、大地紙張配色。',
'preview.shareMenu': '分享 ▾', 'preview.shareMenu': '分享',
'preview.exportMenu': '匯出',
'preview.shareTemplateBadge': '範本',
'preview.shareToX': 'X / Twitter',
'preview.shareToReddit': 'Reddit',
'preview.shareToFacebook': 'Facebook',
'preview.shareToLinkedIn': 'LinkedIn',
'preview.shareToInstagram': 'Instagram',
'preview.shareToXiaohongshu': '小红书',
'preview.copyTemplateLink': '複製範本連結',
'preview.copyShareText': '複製分享文案',
'preview.shareSocialGroup': '分享到社群',
'preview.shareCopyGroup': '複製',
'preview.shareExportGroup': '匯出檔案',
'preview.shareCopied': '已複製',
'preview.shareCopyFailed': '複製失敗',
'preview.shareTextDefault': 'Open Design 模板:{title}',
'preview.openInNewTab': '在新分頁中開啟', 'preview.openInNewTab': '在新分頁中開啟',
'preview.exit': '⤓ 離開', 'preview.exit': '⤓ 離開',
'preview.fullscreen': '⤢ 全螢幕', 'preview.fullscreen': '⤢ 全螢幕',

View file

@ -88,6 +88,13 @@ export interface Dict {
'plugins.availableDetails.integrity': string; 'plugins.availableDetails.integrity': string;
'plugins.availableDetails.permissions': string; 'plugins.availableDetails.permissions': string;
'plugins.availableDetails.capabilitySummary': string; 'plugins.availableDetails.capabilitySummary': string;
'plugins.actions.copyInstallCommand': string;
'plugins.actions.copyPluginId': string;
'plugins.actions.copyReadmeBadge': string;
'plugins.actions.openSourceGithub': string;
'plugins.actions.openSource': string;
'plugins.actions.openHomepage': string;
'plugins.actions.openMarketplace': string;
// App / brand // App / brand
'app.brand': string; 'app.brand': string;
@ -1737,6 +1744,22 @@ export interface Dict {
// Preview modal // Preview modal
'preview.shareMenu': string; 'preview.shareMenu': string;
'preview.exportMenu': string;
'preview.shareTemplateBadge': string;
'preview.shareToX': string;
'preview.shareToReddit': string;
'preview.shareToFacebook': string;
'preview.shareToLinkedIn': string;
'preview.shareToInstagram': string;
'preview.shareToXiaohongshu': string;
'preview.copyTemplateLink': string;
'preview.copyShareText': string;
'preview.shareSocialGroup': string;
'preview.shareCopyGroup': string;
'preview.shareExportGroup': string;
'preview.shareCopied': string;
'preview.shareCopyFailed': string;
'preview.shareTextDefault': string;
'preview.openInNewTab': string; 'preview.openInNewTab': string;
'preview.exit': string; 'preview.exit': string;
'preview.fullscreen': string; 'preview.fullscreen': string;

View file

@ -55,6 +55,10 @@
transition: background-color 120ms ease, color 120ms ease, transition: background-color 120ms ease, color 120ms ease,
border-color 120ms ease; border-color 120ms ease;
} }
.plugin-share-trigger.ghost {
border-radius: var(--radius);
padding: 6px 10px;
}
.plugin-share-trigger--solo { .plugin-share-trigger--solo {
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--bg-panel); background: var(--bg-panel);
@ -429,4 +433,3 @@
min-height: 56px; min-height: 56px;
resize: vertical; resize: vertical;
} }

View file

@ -665,6 +665,16 @@
} }
.share-menu { position: relative; display: inline-block; } .share-menu { position: relative; display: inline-block; }
.template-share-menu > button {
display: inline-flex;
align-items: center;
gap: 6px;
}
.template-share-trigger[aria-expanded='true'] {
background: var(--accent-tint);
border-color: var(--accent);
color: var(--accent);
}
.viewer-action-export { .viewer-action-export {
gap: 6px; gap: 6px;
background: var(--accent); background: var(--accent);
@ -733,6 +743,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
width: 100%;
padding: 8px 10px; padding: 8px 10px;
font-size: 12.5px; font-size: 12.5px;
text-align: left; text-align: left;
@ -747,8 +758,137 @@
border-color: transparent; border-color: transparent;
} }
.share-menu-item:disabled { opacity: 0.45; cursor: not-allowed; } .share-menu-item:disabled { opacity: 0.45; cursor: not-allowed; }
.share-menu-icon { flex: 0 0 auto; width: 18px; text-align: center; font-size: 13px; } .share-menu-icon {
flex: 0 0 auto;
width: 18px;
min-height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 13px;
}
.share-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; } .share-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
.template-share-popover {
min-width: 320px;
gap: 6px;
padding: 8px;
}
.template-share-summary {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 10px 9px;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: 8px;
background: var(--bg-subtle);
}
.template-share-summary__eyebrow {
font-size: 10px;
line-height: 1;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.template-share-summary strong {
font-size: 13px;
line-height: 1.25;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.template-share-summary span:last-child {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.template-share-section {
display: flex;
flex-direction: column;
gap: 4px;
}
.template-share-section + .template-share-section {
border-top: 1px solid var(--border);
margin-top: 2px;
padding-top: 6px;
}
.template-share-section__label {
padding: 2px 8px 0;
color: var(--text-muted);
font-size: 10px;
line-height: 1;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.template-share-platform-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.template-share-platform {
appearance: none;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 7px 9px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-panel);
color: var(--text);
font-size: 12.5px;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.template-share-platform:hover:not(:disabled):not([aria-disabled='true']),
.template-share-platform:focus-visible {
background: var(--bg-subtle);
border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
outline: none;
}
.template-share-platform:disabled,
.template-share-platform[aria-disabled='true'] {
opacity: 0.45;
cursor: not-allowed;
}
.template-share-platform__mark {
flex: 0 0 22px;
width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--text);
color: var(--bg-panel);
font-size: 10px;
font-weight: 700;
letter-spacing: 0;
}
.template-share-platform--reddit .template-share-platform__mark {
background: #ff4500;
color: white;
}
.template-share-platform--facebook .template-share-platform__mark {
background: #1877f2;
color: white;
}
.template-share-platform--linkedin .template-share-platform__mark {
background: #0a66c2;
color: white;
}
.template-share-platform--instagram .template-share-platform__mark {
background: #e4405f;
color: white;
}
.template-share-platform--xiaohongshu .template-share-platform__mark {
background: #ff2442;
color: white;
}
.button-like { .button-like {
display: inline-flex; display: inline-flex;

View file

@ -320,7 +320,7 @@ describe('ExamplesTab', () => {
expect(modal.classList.contains('ds-modal-fullscreen')).toBe(false); expect(modal.classList.contains('ds-modal-fullscreen')).toBe(false);
expect(within(dialog).getByRole('button', { name: /Fullscreen/i })).toBeTruthy(); expect(within(dialog).getByRole('button', { name: /Fullscreen/i })).toBeTruthy();
const shareButton = within(dialog).getByRole('button', { name: 'Share ▾' }); const shareButton = within(dialog).getByRole('button', { name: /Share/i });
fireEvent.click(shareButton); fireEvent.click(shareButton);
fireEvent.click(within(dialog).getByRole('menuitem', { name: /Export as PDF/i })); fireEvent.click(within(dialog).getByRole('menuitem', { name: /Export as PDF/i }));
expect(exportAsPdf).toHaveBeenCalledWith( expect(exportAsPdf).toHaveBeenCalledWith(

View file

@ -1,18 +1,21 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// PluginShareMenu — share affordance contract. // PluginShareMenu — plugin actions affordance contract.
// //
// Locks the share popover behaviour users expect from a "share // Locks the popover behaviour users expect from a plugin-specific
// this plugin" button on a detail modal: copy install command / // actions button on a detail modal: copy install command / plugin id /
// plugin id / share link / markdown badge land on the clipboard, // README badge land on the clipboard, and the popover surfaces source +
// and the popover surfaces source + homepage links when the // homepage links when the manifest carries them.
// manifest carries them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react'; import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client'; import { createRoot, type Root } from 'react-dom/client';
import type { InstalledPluginRecord } from '@open-design/contracts'; import type { InstalledPluginRecord } from '@open-design/contracts';
import { PluginShareMenu } from '../../src/components/plugin-details/PluginShareMenu'; import {
buildPluginShareUrl,
PluginShareMenu,
} from '../../src/components/plugin-details/PluginShareMenu';
import { I18nProvider, type Locale } from '../../src/i18n';
interface MakeArgs { interface MakeArgs {
id: string; id: string;
@ -82,17 +85,26 @@ describe('PluginShareMenu', () => {
container.remove(); container.remove();
}); });
function renderMenu(record: InstalledPluginRecord) { function renderMenu(record: InstalledPluginRecord, locale?: Locale) {
act(() => { act(() => {
root.render(<PluginShareMenu record={record} />); root.render(
locale ? (
<I18nProvider initial={locale}>
<PluginShareMenu record={record} />
</I18nProvider>
) : (
<PluginShareMenu record={record} />
),
);
}); });
} }
function openPopover() { function openPopover(expectedTriggerText = 'More') {
const trigger = container.querySelector( const trigger = container.querySelector(
'.plugin-share-trigger', '.plugin-share-trigger',
) as HTMLButtonElement; ) as HTMLButtonElement;
expect(trigger).toBeTruthy(); expect(trigger).toBeTruthy();
expect(trigger.textContent).toContain(expectedTriggerText);
act(() => { act(() => {
trigger.click(); trigger.click();
}); });
@ -139,12 +151,13 @@ describe('PluginShareMenu', () => {
expect(writes).toContain('od plugin install github:owner/repo@main/sub'); expect(writes).toContain('od plugin install github:owner/repo@main/sub');
}); });
it('copies a fully qualified marketplace share link based on window.location.origin', async () => { it('does not duplicate the template share link action', () => {
renderMenu(make({ id: 'live-dashboard' })); renderMenu(make({ id: 'live-dashboard' }));
openPopover(); openPopover();
clickItem('Copy share link'); const labels = Array.from(
await Promise.resolve(); container.querySelectorAll('.plugin-share-item'),
expect(writes).toContain('https://example.test/marketplace/live-dashboard'); ).map((item) => item.textContent ?? '');
expect(labels.some((label) => label.includes('Copy share link'))).toBe(false);
}); });
it('copies the bare plugin id for paste-into-yaml workflows', async () => { it('copies the bare plugin id for paste-into-yaml workflows', async () => {
@ -155,6 +168,81 @@ describe('PluginShareMenu', () => {
expect(writes).toContain('agentic-ds'); expect(writes).toContain('agentic-ds');
}); });
it('copies a README badge that links back to the marketplace detail page', async () => {
renderMenu(make({
id: 'badge-plugin',
title: 'Badge Plugin',
marketplaceId: 'official',
marketplaceEntryName: 'open-design/badge-plugin',
}));
openPopover();
clickItem('Copy README badge');
await Promise.resolve();
expect(writes.some((value) => (
value.includes('Badge Plugin') &&
value.includes('https://open-design.ai/plugins/badge-plugin')
))).toBe(true);
});
it('does not expose public share artifacts for local-only plugins', () => {
const localOnly = make({
id: 'local-plugin',
sourceKind: 'local',
source: '/tmp/local-plugin',
});
expect(buildPluginShareUrl(localOnly)).toBeNull();
renderMenu(localOnly);
openPopover();
const labels = Array.from(
container.querySelectorAll('.plugin-share-item'),
).map((item) => item.textContent ?? '');
expect(labels.some((label) => label.includes('Copy README badge'))).toBe(false);
});
it('does not expose public share artifacts for private marketplace plugins', () => {
const privateMarketplace = make({
id: 'private-plugin',
sourceKind: 'marketplace',
source: 'private/private-plugin',
marketplaceId: 'private',
marketplaceEntryName: 'private/private-plugin',
});
expect(buildPluginShareUrl(privateMarketplace)).toBeNull();
renderMenu(privateMarketplace);
openPopover();
const labels = Array.from(
container.querySelectorAll('.plugin-share-item'),
).map((item) => item.textContent ?? '');
expect(labels.some((label) => label.includes('Copy README badge'))).toBe(false);
});
it('localizes the plugin action menu labels', () => {
renderMenu(
make({
id: 'zh-plugin',
sourceKind: 'github',
source: 'github:owner/repo',
marketplaceId: 'official',
marketplaceEntryName: 'open-design/zh-plugin',
homepage: 'https://example.test/plugin-home',
}),
'zh-CN',
);
openPopover('更多');
const labels = Array.from(
container.querySelectorAll('.plugin-share-item'),
).map((item) => item.textContent ?? '');
expect(labels).toContain('复制安装命令');
expect(labels).toContain('复制插件 ID');
expect(labels).toContain('复制 README 徽章');
expect(labels).toContain('在 GitHub 打开源码');
expect(labels).toContain('打开项目主页');
expect(labels).toContain('在插件市场打开');
expect(labels.some((label) => label.includes('Copy install command'))).toBe(false);
});
it('exposes Open in marketplace as a navigable item even without external links', () => { it('exposes Open in marketplace as a navigable item even without external links', () => {
renderMenu(make({ id: 'plain' })); renderMenu(make({ id: 'plain' }));
openPopover(); openPopover();
@ -164,6 +252,12 @@ describe('PluginShareMenu', () => {
expect(items.some((b) => b.textContent?.includes('Open in marketplace'))).toBe( expect(items.some((b) => b.textContent?.includes('Open in marketplace'))).toBe(
true, true,
); );
const marketplaceLink = Array.from(
container.querySelectorAll<HTMLAnchorElement>('a.plugin-share-item'),
).find((link) => link.textContent?.includes('Open in marketplace'));
expect(marketplaceLink?.getAttribute('href')).toBe(
'/marketplace/plain',
);
}); });
it('surfaces the GitHub source link when sourceKind is github', () => { it('surfaces the GitHub source link when sourceKind is github', () => {

View file

@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { PreviewModal } from '../../src/components/PreviewModal'; import { PreviewModal } from '../../src/components/PreviewModal';
@ -19,6 +19,7 @@ const baseProps = {
describe('PreviewModal unavailable state', () => { describe('PreviewModal unavailable state', () => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks();
}); });
it('renders the unavailable affordance for a non-html preview', () => { it('renders the unavailable affordance for a non-html preview', () => {
@ -72,7 +73,7 @@ describe('PreviewModal unavailable state', () => {
expect(screen.getByText(/loading/i)).toBeTruthy(); expect(screen.getByText(/loading/i)).toBeTruthy();
}); });
it('disables the Share menu when the active view is unavailable', () => { it('disables the merged share menu when the active view is unavailable', () => {
render( render(
<PreviewModal <PreviewModal
{...baseProps} {...baseProps}
@ -89,13 +90,277 @@ describe('PreviewModal unavailable state', () => {
/>, />,
); );
// The Share menu trigger has no html to export, so it must be // The preview has no html to export or share, so the merged trigger must
// disabled — otherwise users would open the menu and find every // stay disabled instead of opening a menu full of no-op actions.
// export action no-ops.
const share = screen.getByRole('button', { name: /share/i }); const share = screen.getByRole('button', { name: /share/i });
expect((share as HTMLButtonElement).disabled).toBe(true); expect((share as HTMLButtonElement).disabled).toBe(true);
}); });
it('surfaces social sharing and file exports in one menu', () => {
render(
<PreviewModal
{...baseProps}
views={[
{
id: 'preview',
label: 'Preview',
html: '<!doctype html><p>Hello</p>',
},
]}
shareTarget={{
title: 'Landing Template',
url: 'https://example.test/marketplace/landing',
}}
onView={() => {}}
onClose={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /share/i }));
const xShare = screen.getByRole('menuitem', { name: /X \/ Twitter/i });
const redditShare = screen.getByRole('menuitem', { name: /Reddit/i });
expect(xShare).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /Reddit/i })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /Facebook/i })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /LinkedIn/i })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /Instagram/i })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /小红书/i })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /Copy template link/i })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /Export as PDF/i })).toBeTruthy();
expect(xShare.getAttribute('href')).toContain(
'https://twitter.com/intent/tweet?',
);
expect(redditShare.getAttribute('href')).toContain(
'https://www.reddit.com/submit?',
);
expect(redditShare.getAttribute('target')).toBe('_blank');
expect(redditShare.getAttribute('rel')).toBe('noreferrer noopener');
expect(xShare.getAttribute('target')).toBe('_blank');
expect(xShare.getAttribute('rel')).toBe('noreferrer noopener');
expect(xShare.getAttribute('href')).toContain(
'url=https%3A%2F%2Fexample.test%2Fmarketplace%2Flanding',
);
expect(new URL(xShare.getAttribute('href') ?? '').searchParams.get('text')).toBe(
'Open Design template: Landing Template',
);
expect(new URL(redditShare.getAttribute('href') ?? '').searchParams.get('title')).toBe(
'Open Design template: Landing Template',
);
expect(
new URL(
screen.getByRole('menuitem', { name: /Facebook/i }).getAttribute('href') ?? '',
).searchParams.get('quote'),
).toBe('Open Design template: Landing Template');
expect(screen.getByRole('menuitem', { name: /Instagram/i }).getAttribute('href')).toBe(
'https://www.instagram.com/',
);
expect(screen.getByRole('menuitem', { name: /小红书/i }).getAttribute('href')).toBe(
'https://www.xiaohongshu.com/',
);
});
it('keeps the current session open when launching copy-first social destinations', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
const openedWindow = {
opener: window,
location: { href: 'about:blank' },
close: vi.fn(),
};
const open = vi.fn().mockReturnValue(openedWindow);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
Object.defineProperty(window, 'open', {
configurable: true,
value: open,
});
try {
render(
<PreviewModal
{...baseProps}
views={[
{
id: 'preview',
label: 'Preview',
html: '<!doctype html><p>Hello</p>',
},
]}
shareTarget={{
title: 'Landing Template',
url: 'https://example.test/marketplace/landing',
}}
onView={() => {}}
onClose={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /share/i }));
fireEvent.click(screen.getByRole('menuitem', { name: /Instagram/i }));
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(
'Open Design template: Landing Template\nhttps://example.test/marketplace/landing',
);
expect(openedWindow.location.href).toBe('https://www.instagram.com/');
});
expect(open).toHaveBeenCalledWith('about:blank', '_blank');
expect(openedWindow.opener).toBeNull();
expect(openedWindow.close).not.toHaveBeenCalled();
} finally {
Reflect.deleteProperty(navigator, 'clipboard');
Reflect.deleteProperty(window, 'open');
}
});
it('shows copied feedback when clipboard permissions require the fallback path', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('blocked'));
const execCommand = vi.fn((command: string) => command === 'copy');
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: execCommand,
});
try {
render(
<PreviewModal
{...baseProps}
views={[
{
id: 'preview',
label: 'Preview',
html: '<!doctype html><p>Hello</p>',
},
]}
shareTarget={{
title: 'Landing Template',
url: 'https://example.test/marketplace/landing',
}}
onView={() => {}}
onClose={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /share/i }));
fireEvent.click(screen.getByRole('menuitem', { name: /Copy template link/i }));
await waitFor(() => {
expect(screen.getByRole('menuitem', { name: /Copied/i })).toBeTruthy();
});
expect(writeText).toHaveBeenCalledWith('https://example.test/marketplace/landing');
expect(execCommand).toHaveBeenCalledWith('copy');
} finally {
Reflect.deleteProperty(navigator, 'clipboard');
Reflect.deleteProperty(document, 'execCommand');
}
});
it('copies a concise preset share caption with the product, template name, and link', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
try {
render(
<PreviewModal
{...baseProps}
views={[
{
id: 'preview',
label: 'Preview',
html: '<!doctype html><p>Hello</p>',
},
]}
shareTarget={{
title: 'Landing Template',
url: 'https://example.test/marketplace/landing',
}}
onView={() => {}}
onClose={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /share/i }));
fireEvent.click(screen.getByRole('menuitem', { name: /Copy share text/i }));
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(
'Open Design template: Landing Template\nhttps://example.test/marketplace/landing',
);
});
} finally {
Reflect.deleteProperty(navigator, 'clipboard');
}
});
it('keeps social sharing available for custom media views without file exports', () => {
render(
<PreviewModal
{...baseProps}
views={[
{
id: 'media',
label: 'Image',
custom: <div>Poster preview</div>,
},
]}
shareTarget={{
title: 'Media Template',
url: 'https://open-design.ai/plugins/media-template',
}}
onView={() => {}}
onClose={() => {}}
/>,
);
const share = screen.getByRole('button', { name: /share/i });
expect((share as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(share);
expect(screen.getByRole('menuitem', { name: /X \/ Twitter/i })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /Copy template link/i })).toBeTruthy();
expect(screen.queryByRole('menuitem', { name: /Export as PDF/i })).toBeNull();
expect(screen.queryByRole('menuitem', { name: /Download as \.zip/i })).toBeNull();
expect(screen.queryByRole('menuitem', { name: /Export as standalone HTML/i })).toBeNull();
});
it('hides social and copy-link actions when an explicit public share URL is unavailable', () => {
render(
<PreviewModal
{...baseProps}
views={[
{
id: 'preview',
label: 'Preview',
html: '<!doctype html><p>Local-only preview</p>',
},
]}
shareTarget={{
title: 'Local Template',
url: null,
}}
onView={() => {}}
onClose={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /share/i }));
expect(screen.queryByRole('menuitem', { name: /X \/ Twitter/i })).toBeNull();
expect(screen.queryByRole('menuitem', { name: /Copy template link/i })).toBeNull();
expect(screen.getByRole('menuitem', { name: /Export as PDF/i })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /Download as \.zip/i })).toBeTruthy();
expect(screen.getByRole('menuitem', { name: /Export as standalone HTML/i })).toBeTruthy();
});
it('does not call onView for an unavailable view (no fetch to retry)', () => { it('does not call onView for an unavailable view (no fetch to retry)', () => {
// PreviewModal fires onView on mount so the parent can lazy-load // PreviewModal fires onView on mount so the parent can lazy-load
// the active view. For an unavailable view that signal is harmless // the active view. For an unavailable view that signal is harmless

View file

@ -716,14 +716,15 @@ test('home starters html details modal shows metadata links, supports copy query
const copied = await page.evaluate(() => (window as typeof window & { __copiedTexts?: string[] }).__copiedTexts ?? []); const copied = await page.evaluate(() => (window as typeof window & { __copiedTexts?: string[] }).__copiedTexts ?? []);
expect(copied.at(-1)).toBe('Use the {{topic}} template for a polished launch deck.'); expect(copied.at(-1)).toBe('Use the {{topic}} template for a polished launch deck.');
await page.getByTestId('plugin-share-html-metadata-plugin').getByRole('button', { name: /^Share$/i }).click(); await page.getByTestId('plugin-share-html-metadata-plugin').getByRole('button', { name: /^More$/i }).click();
const shareMenu = page.locator('.plugin-share-popover[role="menu"]'); const shareMenu = page.locator('.plugin-share-popover[role="menu"]');
await expect(shareMenu).toBeVisible(); await expect(shareMenu).toBeVisible();
await expect(shareMenu.getByRole('menuitem', { name: /Copy install command/i })).toBeVisible(); await expect(shareMenu.getByRole('menuitem', { name: /Copy install command/i })).toBeVisible();
await expect(shareMenu.getByRole('menuitem', { name: /Copy plugin ID/i })).toBeVisible(); await expect(shareMenu.getByRole('menuitem', { name: /Copy plugin ID/i })).toBeVisible();
await expect(shareMenu.getByRole('menuitem', { name: /Copy share link/i })).toBeVisible(); await expect(shareMenu.getByRole('menuitem', { name: /Copy README badge/i })).toHaveCount(0);
await expect(shareMenu.getByRole('menuitem', { name: /Open source on GitHub/i })).toBeVisible(); await expect(shareMenu.getByRole('menuitem', { name: /Open source on GitHub/i })).toBeVisible();
await expect(shareMenu.getByRole('menuitem', { name: /Open homepage/i })).toBeVisible(); await expect(shareMenu.getByRole('menuitem', { name: /Open homepage/i })).toBeVisible();
await expect(shareMenu.getByRole('menuitem', { name: /Open in marketplace/i })).toBeVisible();
}); });
test('home starters Use plugin from the details modal applies the plugin to the home hero', async ({ page }) => { test('home starters Use plugin from the details modal applies the plugin to the home hero', async ({ page }) => {