Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions app/components/navbar.responsive-breakpoints.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,14 @@ describe('Navbar Responsive Breakpoints & Menu Toggle', () => {
expect(screen.getByRole('button', { name: 'Open menu' })).toBeDefined();
});

it('4. Hides the desktop nav row on mobile and only reveals mobile hamburger controls, via complementary md: classes', () => {
it('4. Hides the desktop nav row on mobile and only reveals mobile hamburger controls, via complementary lg: classes', () => {
const { container } = render(<Navbar />);

const desktopNavRow = container.querySelector('.hidden.items-center.gap-2.md\\:flex');
const desktopNavRow = container.querySelector('.hidden.items-center.gap-2.lg\\:flex');
expect(desktopNavRow).toBeInTheDocument();

const mobileControls = container.querySelector(
'.md\\:hidden.inline-flex.items-center.justify-center.gap-1'
'.lg\\:hidden.inline-flex.items-center.justify-center.gap-1'
);
expect(mobileControls).toBeInTheDocument();
});
Expand Down
142 changes: 140 additions & 2 deletions app/customize/components/ExportPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export function ExportPanel({

// Track async server download states
const [isDownloading, setIsDownloading] = useState(false);
const [isDownloadingTimeLapse, setIsDownloadingTimeLapse] = useState(false);
const [filePathCopied, setFilePathCopied] = useState(false);
const [markdownCopied, setMarkdownCopied] = useState(false);

Expand All @@ -88,7 +89,10 @@ export function ExportPanel({
targetUrl = targetUrl.replace(/&amp;/g, '&');

// 3. SECURE LOCAL WORKSPACE TESTING: Redirect backend calls to your local server instance
if (targetUrl.includes('https://commitpulse.vercel.app')) {
if (
targetUrl.startsWith('https://commitpulse.vercel.app/') ||
targetUrl === 'https://commitpulse.vercel.app'
) {
targetUrl = targetUrl.replace('https://commitpulse.vercel.app', window.location.origin);
}

Expand Down Expand Up @@ -155,6 +159,121 @@ export function ExportPanel({
}
};

const handleDownloadTimeLapse = async () => {
if (!hasUsername || !snippet) return;
try {
setIsDownloadingTimeLapse(true);
const urlMatch = snippet.match(/\((https?:\/\/[^)]+)\)/) || snippet.match(/src="([^"]+)"/);
let targetUrl = urlMatch ? urlMatch[1] : '';
if (!targetUrl) return;
targetUrl = targetUrl.replace(/&amp;/g, '&');
if (
targetUrl.startsWith('https://commitpulse.vercel.app/') ||
targetUrl === 'https://commitpulse.vercel.app'
) {
targetUrl = targetUrl.replace('https://commitpulse.vercel.app', window.location.origin);
}
targetUrl += targetUrl.includes('?') ? '&refresh=true' : '?refresh=true';

const response = await fetch(targetUrl);
const svgText = await response.text();

const parser = new DOMParser();
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const doc = parser.parseFromString(svgText, 'image/svg+xml');
const towers = Array.from(doc.querySelectorAll('.cp-tower'));

towers.sort((a, b) => {
const da = a.getAttribute('data-date') || '';
const db = b.getAttribute('data-date') || '';
return da.localeCompare(db);
});

towers.forEach((t) => {
t.setAttribute('opacity', '0');
(t as HTMLElement).style.animation = 'none';
(t as HTMLElement).style.transition = 'none';
});

// Hide animations from root
const styles = doc.querySelectorAll('style');
styles.forEach((s) => {
s.textContent += ' * { animation: none !important; transition: none !important; }';
});

const framesCount = 90; // 3 seconds at 30fps
const frames: ImageData[] = [];
const canvas = document.createElement('canvas');
canvas.width = parseInt(doc.documentElement.getAttribute('width') || '1000');
canvas.height = parseInt(doc.documentElement.getAttribute('height') || '1000');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('No 2d context');

for (let i = 0; i <= framesCount; i++) {
const progress = i / framesCount;
const visibleCount = Math.floor(progress * towers.length);

for (let j = 0; j < towers.length; j++) {
towers[j].setAttribute('opacity', j < visibleCount ? '1' : '0');
}

const frameSvgText = new XMLSerializer().serializeToString(doc);
const blob = new Blob([frameSvgText], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);

await new Promise((resolve) => {
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
frames.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
URL.revokeObjectURL(url);
resolve(null);
};
img.src = url;
});
}

const streamCanvas = document.createElement('canvas');
streamCanvas.width = canvas.width;
streamCanvas.height = canvas.height;
const streamCtx = streamCanvas.getContext('2d');
if (!streamCtx) throw new Error('No 2d context');

const stream = streamCanvas.captureStream(30);
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm' });
const chunks: BlobPart[] = [];
mediaRecorder.ondataavailable = (e) => chunks.push(e.data);
mediaRecorder.onstop = () => {
const blob = new Blob(chunks, { type: 'video/webm' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `commitpulse-${username}-timelapse.webm`;
a.click();
setIsDownloadingTimeLapse(false);
toast.success('Time-lapse generated!');
};

mediaRecorder.start();

let frameIdx = 0;
const playNextFrame = () => {
if (frameIdx < frames.length) {
streamCtx.putImageData(frames[frameIdx], 0, 0);
frameIdx++;
setTimeout(playNextFrame, 1000 / 30);
} else {
setTimeout(() => mediaRecorder.stop(), 500);
}
};
playNextFrame();
} catch (err) {
console.error(err);
setIsDownloadingTimeLapse(false);
toast.error('Failed to generate time-lapse.');
}
};

const handleDownloadPng = async () => {
if (!hasUsername || !snippet) return;

Expand All @@ -172,7 +291,10 @@ export function ExportPanel({

targetUrl = targetUrl.replace(/&amp;/g, '&');

if (targetUrl.includes('https://commitpulse.vercel.app')) {
if (
targetUrl.startsWith('https://commitpulse.vercel.app/') ||
targetUrl === 'https://commitpulse.vercel.app'
) {
targetUrl = targetUrl.replace('https://commitpulse.vercel.app', window.location.origin);
}

Expand Down Expand Up @@ -333,6 +455,22 @@ export function ExportPanel({
{t('customize.export.share_config', { defaultValue: 'Share Config' })}
</button>

{/* Export Time-Lapse Button */}
<button
type="button"
onClick={handleDownloadTimeLapse}
disabled={!hasUsername || isDownloadingTimeLapse || format === 'action'}
className={`relative inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all duration-200 ${
!hasUsername || isDownloadingTimeLapse || format === 'action'
? 'bg-gray-200/90 border border-black/10 text-gray-500 cursor-not-allowed dark:bg-white/10 dark:border-white/10 dark:text-white/35'
: 'bg-purple-500/10 border border-purple-500/30 text-purple-500 hover:bg-purple-500/20 hover:scale-[1.03] active:scale-[0.97]'
}`}
>
{isDownloadingTimeLapse
? t('customize.export.downloading', { defaultValue: 'Generating...' })
: 'Export Time-Lapse (WebM)'}
</button>

{/* Clipboard Copy Button */}
<button
id="copy-markdown-btn"
Expand Down
Loading