Skip to content

Commit f49d1c3

Browse files
committed
feat: add video share modal with smart captions and fix algorithm name display
- Add ShareExportModal with native share, X (Twitter), and clipboard copy - Add generateShareCaption utility with smart dedup (avoids 'Algorithm Algorithm') - Pass activeAlgorithmName directly as string prop (fixes race condition with export session state) - Add share translations (en/fr/ar) and share-related export preview strings - Add VIDEO_SHARED analytics event and tracking function - Add getExportBlob and exportFileName to useVideoExporter - Add exportAlgorithmMeta tracking in useVideoExporter for metadata preservation - Remove unused shareMeta state and dead shareExport.notNow keys from locales - Fix AutoHidingLegend key prop formatting
1 parent c32d565 commit f49d1c3

13 files changed

Lines changed: 573 additions & 9 deletions

src/components/AutoHidingLegend.jsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,7 @@ function AutoHidingLegend({ legendItems, isComplete }) {
7474
</div>
7575
<div className="space-y-1.5">
7676
{legendItems.map(item => (
77-
<div
78-
key={item.state}
79-
className="flex items-center gap-2"
80-
>
77+
<div key={item.state} className="flex items-center gap-2">
8178
<div
8279
className="w-3 h-3 rounded shadow-sm flex-shrink-0"
8380
style={{ backgroundColor: item.color }}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
/**
2+
* Copyright (c) 2025 Bayan Flow
3+
* Licensed under Elastic License 2.0 OR Commercial
4+
* See LICENSE for details.
5+
*/
6+
7+
import { useState } from 'react';
8+
import { useTranslation } from 'react-i18next';
9+
import { Share, CopySimple, Check, X } from '@phosphor-icons/react';
10+
import { SiX } from 'react-icons/si';
11+
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
12+
import {
13+
fadeOverlayTransition,
14+
modalPanelInitial,
15+
modalPanelAnimate,
16+
modalPanelExit,
17+
modalPanelTransition,
18+
} from '../motion/chromeMotion';
19+
import { generateShareCaption } from '../utils/shareCaption';
20+
21+
/**
22+
* Modal shown after video download offering to share on social media.
23+
* Displays an editable smart caption + platform share buttons.
24+
*
25+
* @param {boolean} open
26+
* @param {string} algorithmName - Display name of the algorithm (e.g. "Bubble Sort")
27+
* @param {Blob | null} videoBlob
28+
* @param {string} videoFileName
29+
* @param {Function} onClose - Close without sharing
30+
* @param {Function} onShare - Called with { platform, text } after share
31+
*/
32+
function ShareExportModal({
33+
open,
34+
algorithmName,
35+
videoBlob,
36+
videoFileName,
37+
onClose,
38+
onShare,
39+
}) {
40+
const { t } = useTranslation();
41+
const reduceMotion = useReducedMotion();
42+
const shareData = generateShareCaption(algorithmName);
43+
const [caption, setCaption] = useState(shareData.fullShareText);
44+
const [copied, setCopied] = useState(false);
45+
46+
const handleNativeShare = async () => {
47+
if (!videoBlob) return;
48+
const file = new File([videoBlob], videoFileName, { type: 'video/mp4' });
49+
const sharePayload = {
50+
title: shareData.title,
51+
text: caption,
52+
files: [file],
53+
};
54+
55+
if (navigator.share && navigator.canShare?.(sharePayload)) {
56+
try {
57+
await navigator.share(sharePayload);
58+
onShare?.({ platform: 'native', text: caption });
59+
return;
60+
} catch {
61+
// User cancelled or share failed
62+
return;
63+
}
64+
}
65+
};
66+
67+
const handleTwitterShare = () => {
68+
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(caption)}`;
69+
window.open(url, '_blank', 'noopener,noreferrer');
70+
onShare?.({ platform: 'twitter', text: caption });
71+
};
72+
73+
const handleCopyText = async () => {
74+
try {
75+
await navigator.clipboard.writeText(caption);
76+
setCopied(true);
77+
onShare?.({ platform: 'clipboard', text: caption });
78+
setTimeout(() => setCopied(false), 2000);
79+
} catch {
80+
// Clipboard API not available
81+
}
82+
};
83+
84+
const canNativeShare =
85+
typeof navigator !== 'undefined' && !!navigator.share && !!videoBlob;
86+
87+
return (
88+
<AnimatePresence>
89+
{open && (
90+
<motion.div
91+
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60"
92+
initial={{ opacity: 0 }}
93+
animate={{ opacity: 1 }}
94+
exit={{ opacity: 0 }}
95+
transition={fadeOverlayTransition(reduceMotion)}
96+
role="dialog"
97+
aria-modal="true"
98+
aria-labelledby="share-modal-title"
99+
aria-describedby="share-modal-desc"
100+
>
101+
<motion.div
102+
className="relative bg-surface rounded-xl shadow-2xl w-full max-w-lg p-6"
103+
initial={modalPanelInitial(reduceMotion)}
104+
animate={modalPanelAnimate()}
105+
exit={modalPanelExit(reduceMotion)}
106+
transition={modalPanelTransition(reduceMotion)}
107+
>
108+
<button
109+
type="button"
110+
onClick={onClose}
111+
className="absolute top-4 right-4 rtl:right-auto rtl:left-4 p-1.5 rounded-full text-text-secondary hover:text-text-primary transition-colors"
112+
aria-label={t('shareExport.close')}
113+
>
114+
<X size={20} weight="bold" aria-hidden="true" />
115+
</button>
116+
117+
<h2
118+
id="share-modal-title"
119+
className="text-lg font-bold text-text-primary mb-1"
120+
>
121+
{t('shareExport.title')}
122+
</h2>
123+
<p
124+
id="share-modal-desc"
125+
className="text-sm text-text-secondary mb-4"
126+
>
127+
{t('shareExport.description')}
128+
</p>
129+
130+
<label
131+
htmlFor="share-caption"
132+
className="block text-sm font-medium text-text-primary mb-1.5"
133+
>
134+
{t('shareExport.captionLabel')}
135+
</label>
136+
<textarea
137+
id="share-caption"
138+
rows={8}
139+
value={caption}
140+
onChange={e => setCaption(e.target.value)}
141+
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-3 py-2.5 text-sm text-text-primary placeholder:text-text-secondary resize-none focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent transition-all"
142+
/>
143+
144+
<div className="flex flex-wrap gap-2 mt-4">
145+
{canNativeShare && (
146+
<button
147+
type="button"
148+
onClick={handleNativeShare}
149+
className="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-teal-500 hover:bg-teal-600 text-white font-medium text-sm transition-colors"
150+
aria-label={t('shareExport.shareNative')}
151+
>
152+
<Share size={16} weight="bold" aria-hidden="true" />
153+
{t('shareExport.shareNative')}
154+
</button>
155+
)}
156+
<button
157+
type="button"
158+
onClick={handleTwitterShare}
159+
className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 hover:bg-gray-500/5 dark:hover:bg-gray-500/10 text-text-primary font-medium text-sm transition-colors"
160+
aria-label={t('shareExport.shareOnX')}
161+
>
162+
<SiX className="w-4 h-4" aria-hidden="true" />
163+
{t('shareExport.shareOnX')}
164+
</button>
165+
<button
166+
type="button"
167+
onClick={handleCopyText}
168+
className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-teal-500 hover:bg-teal-500/5 dark:hover:bg-teal-500/10 text-text-primary font-medium text-sm transition-colors"
169+
aria-label={t('shareExport.copyCaption')}
170+
>
171+
{copied ? (
172+
<Check
173+
size={16}
174+
weight="bold"
175+
aria-hidden="true"
176+
className="text-teal-500"
177+
/>
178+
) : (
179+
<CopySimple size={16} weight="bold" aria-hidden="true" />
180+
)}
181+
{copied
182+
? t('shareExport.copied')
183+
: t('shareExport.copyCaption')}
184+
</button>
185+
</div>
186+
</motion.div>
187+
</motion.div>
188+
)}
189+
</AnimatePresence>
190+
);
191+
}
192+
193+
export default ShareExportModal;
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* Copyright (c) 2025 Bayan Flow
3+
* Licensed under Elastic License 2.0 OR Commercial
4+
* See LICENSE for details.
5+
*/
6+
7+
import { render, screen, fireEvent } from '@testing-library/react';
8+
import { describe, it, expect, vi, beforeEach } from 'vitest';
9+
import ShareExportModal from './ShareExportModal';
10+
11+
const mockTranslations = {
12+
'shareExport.title': 'Share your visualization',
13+
'shareExport.description': 'Your video is ready!',
14+
'shareExport.captionLabel': 'Caption',
15+
'shareExport.shareNative': 'Share',
16+
'shareExport.shareOnX': 'Share on X',
17+
'shareExport.copyCaption': 'Copy caption',
18+
'shareExport.copied': 'Copied!',
19+
'shareExport.close': 'Close',
20+
};
21+
22+
vi.mock('react-i18next', () => ({
23+
useTranslation: () => ({
24+
t: key => mockTranslations[key] || key,
25+
i18n: { language: 'en' },
26+
}),
27+
}));
28+
29+
const defaultProps = {
30+
open: true,
31+
algorithmName: 'Bubble Sort',
32+
videoBlob: new Blob(['fake-video'], { type: 'video/mp4' }),
33+
videoFileName: 'bubble-sort.mp4',
34+
onClose: vi.fn(),
35+
onShare: vi.fn(),
36+
};
37+
38+
function renderModal(overrides = {}) {
39+
return render(<ShareExportModal {...defaultProps} {...overrides} />);
40+
}
41+
42+
describe('ShareExportModal', () => {
43+
beforeEach(() => {
44+
vi.clearAllMocks();
45+
});
46+
47+
it('renders nothing when closed', () => {
48+
renderModal({ open: false });
49+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
50+
});
51+
52+
it('renders the dialog when open', () => {
53+
renderModal();
54+
expect(screen.getByRole('dialog')).toBeInTheDocument();
55+
expect(screen.getByText('Share your visualization')).toBeInTheDocument();
56+
expect(screen.getByText('Your video is ready!')).toBeInTheDocument();
57+
});
58+
59+
it('displays caption textarea with generated text', () => {
60+
renderModal();
61+
const textarea = screen.getByLabelText('Caption');
62+
expect(textarea).toBeInTheDocument();
63+
expect(textarea.value).toContain('Bubble Sort');
64+
expect(textarea.value).toContain('bayanflow.com');
65+
});
66+
67+
it('calls onClose when close button is clicked', () => {
68+
renderModal();
69+
fireEvent.click(screen.getByLabelText('Close'));
70+
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
71+
});
72+
73+
it('allows editing the caption', () => {
74+
renderModal();
75+
const textarea = screen.getByLabelText('Caption');
76+
fireEvent.change(textarea, { target: { value: 'Custom caption text' } });
77+
expect(textarea.value).toBe('Custom caption text');
78+
});
79+
80+
it('opens Twitter share intent with updated caption', () => {
81+
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => {});
82+
renderModal();
83+
84+
const textarea = screen.getByLabelText('Caption');
85+
fireEvent.change(textarea, {
86+
target: { value: 'My custom tweet text' },
87+
});
88+
89+
fireEvent.click(screen.getByText('Share on X'));
90+
expect(openSpy).toHaveBeenCalledTimes(1);
91+
expect(openSpy.mock.calls[0][0]).toContain('twitter.com/intent/tweet');
92+
expect(openSpy.mock.calls[0][0]).toContain('My%20custom%20tweet%20text');
93+
expect(defaultProps.onShare).toHaveBeenCalledWith({
94+
platform: 'twitter',
95+
text: 'My custom tweet text',
96+
});
97+
openSpy.mockRestore();
98+
});
99+
100+
it('does not show native Share button when navigator.share unavailable', () => {
101+
vi.stubGlobal('navigator', {});
102+
renderModal();
103+
expect(screen.queryByText('Share')).not.toBeInTheDocument();
104+
});
105+
106+
it('shows native Share button when navigator.share is available', () => {
107+
vi.stubGlobal('navigator', { share: vi.fn(), canShare: vi.fn(() => true) });
108+
renderModal();
109+
expect(screen.getByText('Share')).toBeInTheDocument();
110+
});
111+
112+
it('handles clipboard copy with success feedback', async () => {
113+
vi.useFakeTimers();
114+
const writeTextMock = vi.fn().mockResolvedValue(undefined);
115+
vi.stubGlobal('navigator', {
116+
clipboard: { writeText: writeTextMock },
117+
});
118+
119+
renderModal();
120+
fireEvent.click(screen.getByText('Copy caption'));
121+
122+
await vi.waitFor(() => {
123+
expect(writeTextMock).toHaveBeenCalled();
124+
expect(screen.getByText('Copied!')).toBeInTheDocument();
125+
});
126+
127+
expect(defaultProps.onShare).toHaveBeenCalledWith({
128+
platform: 'clipboard',
129+
text: expect.stringContaining('Bubble Sort'),
130+
});
131+
132+
vi.useRealTimers();
133+
});
134+
});

src/i18n/locales/ar/translation.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,8 +355,12 @@
355355
"exportOrientationVertical": "عمودي",
356356
"exportOrientationVerticalDesc": "9:16 • Shorts, Reels, TikTok",
357357
"exportPreview": "الفيديو جاهز",
358-
"exportPreviewDesc": "معاينة الفيديو وتنزيله عند الحاجة.",
358+
"exportPreviewDesc": "معاينة الفيديو أو تنزيله أو مشاركته.",
359359
"downloadVideo": "تنزيل",
360+
"shareVideo": "مشاركة",
361+
"shareOnX": "مشاركة على X",
362+
"copyLink": "نسخ الرابط",
363+
"linkCopied": "تم النسخ!",
360364
"closePreview": "إغلاق",
361365
"goFullScreen": "ملء الشاشة (F)",
362366
"exitFullScreen": "الخروج من ملء الشاشة (Esc)",
@@ -382,6 +386,15 @@
382386
"videoExport": {
383387
"noSteps": "لا توجد خطوات لعرضها"
384388
},
389+
"shareExport": {
390+
"title": "شارك التصور البصري الخاص بك",
391+
"description": "فيديوك جاهز! شاركه على وسائل التواصل الاجتماعي مع تعليق ذكي.",
392+
"captionLabel": "التعليق",
393+
"shareNative": "مشاركة",
394+
"shareOnX": "مشاركة على X",
395+
"copyCaption": "نسخ التعليق",
396+
"copied": "تم النسخ!"
397+
},
385398
"complexity_panel": {
386399
"title": "تحليل التعقيد",
387400
"timeComplexity": "التعقيد الزمني",

0 commit comments

Comments
 (0)