Skip to content
12 changes: 11 additions & 1 deletion e2e/share.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ test.describe('Share Functionality', () => {
const shareButton = page.getByRole('button', { name: 'Share' });
await shareButton.click();

// Click the Copy button inside the Share Modal
const copyButton = page.getByRole('dialog').getByRole('button', { name: /Copy/i });
await expect(copyButton).toBeVisible();
await copyButton.click();

// Should show success message
await expect(page.getByText('Link copied to clipboard')).toBeVisible({ timeout: 5000 });

Expand All @@ -33,12 +38,17 @@ test.describe('Share Functionality', () => {
const shareButton = page.getByRole('button', { name: 'Share' });
await shareButton.click();

// Click the Copy button inside the Share Modal
const copyButton = page.getByRole('dialog').getByRole('button', { name: /Copy/i });
await expect(copyButton).toBeVisible();
await copyButton.click();

// Wait for clipboard to be populated
await expect(page.getByText('Link copied to clipboard')).toBeVisible({ timeout: 5000 });

// Get the shareable link from clipboard
const shareableLink = await page.evaluate(() => navigator.clipboard.readText());

// Validate that we got a non-empty string
expect(shareableLink, 'Shareable link should be a non-empty string').toBeTruthy();
expect(typeof shareableLink, 'Shareable link should be a string').toBe('string');
Expand Down
108 changes: 51 additions & 57 deletions src/components/PlaygroundSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import useAppStore from "../store/store";
import { message, Tooltip } from "antd";
import FullScreenModal from "./FullScreenModal";
import SettingsModal from "./SettingsModal";
import ShareModal from "./ShareModal";
import tour from "./Tour";
import "../styles/components/PlaygroundSidebar.css";

const PlaygroundSidebar = () => {
const {
const {
isEditorsVisible,
isPreviewVisible,
isProblemPanelVisible,
Expand All @@ -21,7 +22,7 @@ const PlaygroundSidebar = () => {
setPreviewVisible,
setProblemPanelVisible,
setAIChatOpen,
generateShareableLink,
setShareModalOpen,
setSettingsOpen,
} = useAppStore((state) => ({
isEditorsVisible: state.isEditorsVisible,
Expand All @@ -32,19 +33,12 @@ const PlaygroundSidebar = () => {
setPreviewVisible: state.setPreviewVisible,
setProblemPanelVisible: state.setProblemPanelVisible,
setAIChatOpen: state.setAIChatOpen,
generateShareableLink: state.generateShareableLink,
setShareModalOpen: state.setShareModalOpen,
setSettingsOpen: state.setSettingsOpen,
}));

const handleShare = async () => {
try {
const link = generateShareableLink();
await navigator.clipboard.writeText(link);
void message.success('Link copied to clipboard!');
} catch (err) {
console.error('Error copying to clipboard:', err);
void message.error('Failed to copy link to clipboard');
}
const handleShare = () => {
setShareModalOpen(true);
};

const handleSettings = () => {
Expand Down Expand Up @@ -85,20 +79,20 @@ const PlaygroundSidebar = () => {
}

const navTop: NavItem[] = [
{
title: "Editor",
icon: IoCodeSlash,
{
title: "Editor",
icon: IoCodeSlash,
onClick: handleEditorToggle,
active: isEditorsVisible
},
{
title: "Preview",
{
title: "Preview",
icon: VscOutput,
onClick: handlePreviewToggle,
active: isPreviewVisible
},
{
title: "Problems",
{
title: "Problems",
icon: FiTerminal,
onClick: () => setProblemPanelVisible(!isProblemPanelVisible),
active: isProblemPanelVisible
Expand All @@ -123,8 +117,8 @@ const PlaygroundSidebar = () => {
onClick: () => setAIChatOpen(!isAIChatOpen),
active: isAIChatOpen
},
{
title: "Fullscreen",
{
title: "Fullscreen",
component: <FullScreenModal />
},
];
Expand All @@ -136,18 +130,18 @@ const PlaygroundSidebar = () => {
}

const navBottom: NavBottomItem[] = [
{
title: "Share",
{
title: "Share",
icon: FiShare2,
onClick: () => void handleShare()
onClick: handleShare
},
{
title: "Start Tour",
{
title: "Start Tour",
icon: FaCirclePlay,
onClick: () => void handleStartTour()
},
{
title: "Settings",
{
title: "Settings",
icon: FiSettings,
onClick: handleSettings
},
Expand All @@ -158,46 +152,46 @@ const PlaygroundSidebar = () => {
<nav className="playground-sidebar-nav">
{navTop.map(({ title, icon: Icon, component, onClick, active }) => (
<Tooltip key={title} title={title} placement="right">
<div
role="button"
aria-label={title}
tabIndex={0}
onClick={onClick}
className={`group playground-sidebar-nav-item ${
active ? 'playground-sidebar-nav-item-active' : 'playground-sidebar-nav-item-inactive'
} tour-${title.toLowerCase().replace(' ', '-')}`}
>
{component ? (
<div className="playground-sidebar-nav-item-icon-container">
{component}
</div>
) : Icon ? (
<Icon size={20} />
) : null}
<span className="playground-sidebar-nav-item-title">{title}</span>
</div>
<div
role="button"
aria-label={title}
tabIndex={0}
onClick={onClick}
className={`group playground-sidebar-nav-item ${active ? 'playground-sidebar-nav-item-active' : 'playground-sidebar-nav-item-inactive'
} tour-${title.toLowerCase().replace(' ', '-')}`}
>
{component ? (
<div className="playground-sidebar-nav-item-icon-container">
{component}
</div>
) : Icon ? (
<Icon size={20} />
) : null}
<span className="playground-sidebar-nav-item-title">{title}</span>
</div>
</Tooltip>
))}
</nav>

<nav className="playground-sidebar-nav-bottom">
{navBottom.map(({ title, icon: Icon, onClick }) => (
<Tooltip key={title} title={title} placement="right">
<div
role="button"
aria-label={title}
tabIndex={0}
onClick={onClick}
className={`group playground-sidebar-nav-bottom-item tour-${title.toLowerCase().replace(' ', '-')}`}
>
<Icon size={18} />
<span className="playground-sidebar-nav-item-title">{title}</span>
</div>
<div
role="button"
aria-label={title}
tabIndex={0}
onClick={onClick}
className={`group playground-sidebar-nav-bottom-item tour-${title.toLowerCase().replace(' ', '-')}`}
>
<Icon size={18} />
<span className="playground-sidebar-nav-item-title">{title}</span>
</div>
</Tooltip>
))}
</nav>
</aside>,
<SettingsModal key="settings-modal" />
<SettingsModal key="settings-modal" />,
<ShareModal key="share-modal" />
];
};

Expand Down
153 changes: 153 additions & 0 deletions src/components/ShareModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import React, { useState } from "react";
import { Modal, Input, Button, message, Tooltip, Space } from "antd";
import { CopyOutlined, CheckOutlined, TwitterOutlined, LinkedinOutlined, MailOutlined } from "@ant-design/icons";
import useAppStore from "../store/store";

const ShareModal: React.FC = () => {
const {
isShareModalOpen,
setShareModalOpen,
generateShareableLink,
backgroundColor,
textColor
} = useAppStore((state) => ({
isShareModalOpen: state.isShareModalOpen,
setShareModalOpen: state.setShareModalOpen,
generateShareableLink: state.generateShareableLink,
backgroundColor: state.backgroundColor,
textColor: state.textColor,
}));

const isDarkMode = backgroundColor === '#121212';

const [hasCopied, setHasCopied] = useState(false);

// We compute this only when the modal opens to avoid unnecessary re-computations
const [shareLink, setShareLink] = useState("");

const handleOpen = () => {
setShareLink(generateShareableLink());
};

const handleClose = () => {
setShareModalOpen(false);
// Reset copy state when modal closes
setTimeout(() => setHasCopied(false), 300);
};

const handleCopy = async () => {
try {
await navigator.clipboard.writeText(shareLink);
setHasCopied(true);
void message.success("Link copied to clipboard!");
setTimeout(() => setHasCopied(false), 3000);
} catch (err) {
console.error("Failed to copy link: ", err);
void message.error("Failed to copy link to clipboard.");
}
};

const shareToTwitter = () => {
const text = encodeURIComponent("Check out this Accord Project template!");
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareLink)}&text=${text}`, "_blank");
};

const shareToLinkedIn = () => {
window.open(`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareLink)}`, "_blank");
};

const shareViaEmail = () => {
const subject = encodeURIComponent("Accord Project Template");
const body = encodeURIComponent(`Check out this template I built using the Accord Project Template Playground:\n\n${shareLink}`);
window.location.href = `mailto:?subject=${subject}&body=${body}`;
};

return (
<Modal
title="Share Template"
open={isShareModalOpen}
onCancel={handleClose}
afterOpenChange={(open) => {
if (open) handleOpen();
}}
className={isDarkMode ? 'dark-modal' : ''}
footer={null} // We manage our own footer/layout to match SettingsModal
width="90%"
style={{ maxWidth: 480 }}
centered
>
<div className="space-y-6 py-4">

{/* Information Text */}
<div className="flex flex-col gap-2">
<p className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Anyone with this link will be able to view and edit this template.
</p>
</div>

{/* Link Input & Copy */}
<div className="flex flex-col sm:flex-row gap-3">
<Input
readOnly
value={shareLink}
onFocus={(e) => e.target.select()}
className={`font-mono text-sm flex-1 ${isDarkMode ? 'bg-[#1e1e1e] text-gray-300 border-gray-600' : ''}`}
/>
<Button
type="primary"
icon={hasCopied ? <CheckOutlined /> : <CopyOutlined />}
onClick={() => void handleCopy()}
className="w-full sm:w-auto"
>
{hasCopied ? "Copied!" : "Copy"}
</Button>
</div>

<hr className={isDarkMode ? 'border-gray-600' : 'border-gray-200'} />

{/* Social Share */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm sm:text-base" style={{ color: textColor }}>
Share Externally
</h4>
<p className={`text-xs sm:text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Post your template link directly to your social platforms
</p>
</div>
<div className="flex-shrink-0">
<Space size="middle">
<Tooltip title="Share on Twitter/X">
<Button
shape="circle"
icon={<TwitterOutlined />}
onClick={shareToTwitter}
style={{ color: '#1DA1F2', borderColor: '#1DA1F2', background: 'transparent' }}
/>
</Tooltip>
<Tooltip title="Share on LinkedIn">
<Button
shape="circle"
icon={<LinkedinOutlined />}
onClick={shareToLinkedIn}
style={{ color: '#0A66C2', borderColor: '#0A66C2', background: 'transparent' }}
/>
</Tooltip>
<Tooltip title="Share via Email">
<Button
shape="circle"
icon={<MailOutlined />}
onClick={shareViaEmail}
style={{ background: 'transparent' }}
className={isDarkMode ? 'text-gray-300 border-gray-500' : 'text-gray-600'}
/>
</Tooltip>
</Space>
</div>
</div>
</div>
</Modal>
);
};

export default ShareModal;
Loading