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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions e2e/resume-linkedin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,31 @@ test.describe('Resume LinkedIn Format Page', () => {
// Should have at least one newline per bullet (bullets appear after newlines)
expect(newlineCount).toBeGreaterThanOrEqual(bulletCount - 1);
});

test('should copy field text to clipboard when copy button is clicked', async ({ page, context }) => {
// Grant clipboard permissions for Chromium
await context.grantPermissions(['clipboard-read', 'clipboard-write']);

// Click the first non-description copy button (e.g., Title)
const firstCopyBtn = page.locator('.copy-btn:not(.copy-description)').first();
const expectedText = await firstCopyBtn.getAttribute('data-copy');
await firstCopyBtn.click();

// Verify the button shows success feedback
await expect(firstCopyBtn).toHaveText('✓');

// Read from clipboard and verify
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
expect(clipboardText).toBe(expectedText);
});

test('should restore focus to copy button after clicking', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);

const firstCopyBtn = page.locator('.copy-btn').first();
await firstCopyBtn.click();

// The copy button should retain focus after the copy operation
await expect(firstCopyBtn).toBeFocused();
});
});
38 changes: 1 addition & 37 deletions src/pages/resume/linkedin.astro
Original file line number Diff line number Diff line change
Expand Up @@ -224,41 +224,5 @@ const educationData: EducationData[] = degrees?.map((degree: { school: string; d
</PageLayout>

<script>
function initCopyButtons() {
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', async () => {
let textToCopy = '';

if (btn.classList.contains('copy-description')) {
const descriptionElement = btn.closest('.flex')?.querySelector('[data-description]');
textToCopy = descriptionElement?.textContent?.trim() || '';
} else {
textToCopy = btn.getAttribute('data-copy') || '';
}

try {
await navigator.clipboard.writeText(textToCopy);

const original = btn.textContent;
btn.textContent = '✓';
btn.classList.add('!bg-green-600');

setTimeout(() => {
btn.textContent = original;
btn.classList.remove('!bg-green-600');
}, 1500);
} catch (err) {
console.error('Failed to copy text:', err);
const original = btn.textContent;
btn.textContent = '✗';
setTimeout(() => {
btn.textContent = original;
}, 1500);
}
});
});
}

// Re-initialize after Astro view transitions
document.addEventListener('astro:page-load', initCopyButtons);
import '../../scripts/linkedin-copy';
</script>
73 changes: 73 additions & 0 deletions src/scripts/linkedin-copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Copy text to clipboard with fallback for Safari/iPad OS.
* Safari silently resolves navigator.clipboard.writeText() without actually
* writing to the clipboard (the promise succeeds but content is blank), so we
* cannot use the modern Clipboard API with a catch fallback. Instead, we use
* textarea + execCommand('copy') which works reliably across all browsers.
*/
function copyToClipboard(text: string, triggerElement?: Element): boolean {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '-9999px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);

// iOS/iPad Safari requires both selection methods
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);

let success = false;
try {
success = document.execCommand('copy');
} catch {
success = false;
}
document.body.removeChild(textarea);

// Restore focus to the trigger element after textarea removal
if (triggerElement instanceof HTMLElement) {
triggerElement.focus();
}

return success;
}

function initCopyButtons() {
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', () => {
let textToCopy = '';

if (btn.classList.contains('copy-description')) {
const descriptionElement = btn.parentElement?.querySelector('[data-description]') as HTMLElement | null;
textToCopy = descriptionElement?.innerText?.trim() || '';
} else {
textToCopy = btn.getAttribute('data-copy') || '';
}

const success = copyToClipboard(textToCopy, btn);

if (success) {
const original = btn.textContent;
btn.textContent = '✓';
btn.classList.add('!bg-green-600');

setTimeout(() => {
btn.textContent = original;
btn.classList.remove('!bg-green-600');
}, 1500);
} else {
const original = btn.textContent;
btn.textContent = '✗';
setTimeout(() => {
btn.textContent = original;
}, 1500);
}
});
});
}

// Re-initialize after Astro view transitions
document.addEventListener('astro:page-load', initCopyButtons);
Loading