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
52 changes: 52 additions & 0 deletions src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { copyToClipboard } from '../lib/utils/clipboard';
import { toast } from 'sonner';

interface CopyButtonProps {
text: string;
label?: string;
size?: number;
successMessage?: string;
showText?: boolean;
}

export function CopyButton({ text, label = 'Copy', size = 11, successMessage = 'Copied!', showText = false }: CopyButtonProps) {
const [copied, setCopied] = useState(false);

const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
const success = await copyToClipboard(text);
if (success) {
setCopied(true);
toast.success(successMessage);
setTimeout(() => setCopied(false), 2000);
}
};

return (
<button
onClick={handleCopy}
aria-label={label}
title={label}
style={{
background: 'none',
border: 'none',
padding: '2px',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: copied ? 'var(--status-safe)' : 'var(--text-muted)',
borderRadius: '4px',
transition: 'color 0.2s ease',
gap: '4px',
}}
onMouseEnter={(e) => { if (!copied) e.currentTarget.style.color = 'var(--text-primary)'; }}
onMouseLeave={(e) => { if (!copied) e.currentTarget.style.color = 'var(--text-muted)'; }}
>
{copied ? <Check size={size} /> : <Copy size={size} />}
{showText && <span style={{ fontSize: '12px' }}>{copied ? 'Copied!' : 'Copy'}</span>}
</button>
);
}
42 changes: 42 additions & 0 deletions src/lib/utils/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { toast } from 'sonner';

/**
* Copies text to clipboard with a fallback for older browsers or environments
* where navigator.clipboard is unavailable (like HTTP without localhost).
*/
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}

// Fallback using execCommand for unsupported contexts
const textArea = document.createElement('textarea');
textArea.value = text;

// Avoid scrolling to bottom
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.position = 'fixed';

document.body.appendChild(textArea);
textArea.focus();
textArea.select();

const successful = document.execCommand('copy');
document.body.removeChild(textArea);

if (!successful) {
throw new Error('Fallback copy failed');
}
return true;
} catch (error) {
if (error instanceof Error && error.name === 'NotAllowedError') {
toast.error('Clipboard permission denied');
} else {
toast.error('Failed to copy to clipboard');
}
return false;
}
}
32 changes: 10 additions & 22 deletions src/pages/app/ProfilePage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Copy, Check, ChevronRight, Bell, Trash2, Download, Calculator, AlertTriangle, LogOut, ExternalLink, MessageSquare, Calendar, Plus, Users, Mail, Loader2, Heart, Star, BookOpen } from 'lucide-react';
import { ChevronRight, Bell, Trash2, Download, Calculator, AlertTriangle, LogOut, ExternalLink, MessageSquare, Calendar, Plus, Users, Mail, Loader2, Heart, Star, BookOpen } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { useQuery } from '@tanstack/react-query';
import { NavBar } from '../../components/NavBar';
Expand All @@ -12,9 +12,9 @@ import { isPushSupported, getPushPermission, hasActiveSubscription, subscribeToP
import { FeedbackSheet } from '../../components/FeedbackSheet';
import { signOutGlobal } from '../../components/AuthProvider';
import { useUserTags, useDeleteTag, MAX_ACTIVE_TAGS } from '../../hooks/useUserTags';
import { TagPill } from '../../components/TagPill';
import { AddTagSheet } from '../../components/AddTagSheet';
import { BottomSheet } from '../../components/BottomSheet';
import { CopyButton } from '../../components/CopyButton';
import { logEvent } from '../../lib/analytics';

export default function ProfilePage() {
Expand Down Expand Up @@ -137,15 +137,6 @@ export default function ProfilePage() {

const initials = displayName.split(' ').map((n: string) => n[0]).join('').slice(0, 2).toUpperCase();

const [copied, setCopied] = useState(false);

const handleCopy = () => {
navigator.clipboard.writeText(hubCode);
setCopied(true);
toast.success('Hub code copied!');
setTimeout(() => setCopied(false), 2000);
};

const handleDeleteAccount = async () => {
if (deleteInput !== 'DELETE') return;
setDeleting(true);
Expand Down Expand Up @@ -404,8 +395,9 @@ export default function ProfilePage() {
<span className="badge badge-info">{authUser.branch}</span>
)}
{displayRole !== 'teacher' && (
<span className="t-mono" style={{ color: 'var(--text-secondary)', padding: '3px 10px', background: 'var(--bg-elevated)', borderRadius: 'var(--radius-pill)' }}>
<span className="t-mono" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--text-secondary)', padding: '3px 10px', background: 'var(--bg-elevated)', borderRadius: 'var(--radius-pill)' }}>
Roll {classRoll}
{classRoll !== '—' && <CopyButton text={classRoll} label="Copy class roll number" size={12} />}
</span>
)}
</div>
Expand Down Expand Up @@ -483,15 +475,7 @@ export default function ProfilePage() {
label: 'Hub Code',
value: hubCode,
action: (
<button
id="copy-hub-code"
onClick={handleCopy}
className="t-label"
style={{ background: 'none', border: 'none', cursor: 'pointer', color: copied ? 'var(--status-safe)' : 'var(--accent-primary)', display: 'flex', alignItems: 'center', gap: 4 }}
>
{copied ? <Check size={13} style={{ color: 'var(--status-safe)' }} /> : <Copy size={13} />}
<span>{copied ? 'Copied!' : 'Copy'}</span>
</button>
<CopyButton text={hubCode} label="Copy hub code" showText={true} size={13} successMessage="Hub code copied!" />
)
},
{
Expand Down Expand Up @@ -527,7 +511,11 @@ export default function ProfilePage() {
)
},
...(role !== 'teacher' ? [
{ label: 'University Roll', value: universityRoll },
{
label: 'University Roll',
value: universityRoll,
action: universityRoll !== '—' ? <CopyButton text={universityRoll} label="Copy university roll number" /> : undefined
},
{
label: 'Curriculum',
value: 'Subjects',
Expand Down
57 changes: 8 additions & 49 deletions src/pages/app/SectionDirectoryPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ArrowLeft, X, Users, Mail, BookOpen, Copy } from 'lucide-react';
import { ArrowLeft, X, Users, Mail, BookOpen } from 'lucide-react';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
import Skeleton from 'react-loading-skeleton';
import { useQuery } from '@tanstack/react-query';
Expand All @@ -10,6 +10,7 @@ import { useSectionMembers, useSection } from '../../hooks/useSectionMembers';
import { useUserTagsBatch, useDeleteTag } from '../../hooks/useUserTags';
import { useAppStore } from '../../store/appStore';
import { TagPill, TagOverflow } from '../../components/TagPill';
import { CopyButton } from '../../components/CopyButton';
import { toast } from 'sonner';

const MAX_VISIBLE_TAGS = 3;
Expand Down Expand Up @@ -306,30 +307,7 @@ export default function SectionDirectoryPage() {
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)' }}>
+91 {teacher.phone}
</span>
<button
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(teacher.phone || '');
toast.success('Phone number copied!');
}}
style={{
background: 'none',
border: 'none',
padding: '2px',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-muted)',
borderRadius: '4px',
transition: 'color 0.2s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--text-primary)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-muted)'; }}
title="Copy Phone Number"
>
<Copy size={11} />
</button>
<CopyButton text={teacher.phone} label="Copy phone number" successMessage="Phone number copied!" />
</div>
)}
</div>
Expand Down Expand Up @@ -518,13 +496,17 @@ export default function SectionDirectoryPage() {
</span>
{member.classRoll && (
<span className="t-mono-sm" style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
background: 'rgba(255, 255, 255, 0.05)',
padding: '1px 5px',
borderRadius: 4,
fontSize: '12px',
color: 'var(--text-secondary)',
}}>
{member.classRoll}
<CopyButton text={member.classRoll} label="Copy class roll number" />
</span>
)}
{member.role === 'cr' && (
Expand All @@ -547,30 +529,7 @@ export default function SectionDirectoryPage() {
<span style={{ fontSize: '12px', color: 'var(--text-secondary)', fontFamily: 'var(--font-mono)' }}>
+91 {member.phone}
</span>
<button
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(member.phone!);
toast.success('Phone number copied!');
}}
style={{
background: 'none',
border: 'none',
padding: '2px',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text-muted)',
borderRadius: '4px',
transition: 'color 0.2s ease',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--text-primary)'; }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--text-muted)'; }}
title="Copy Phone Number"
>
<Copy size={11} />
</button>
<CopyButton text={member.phone} label="Copy phone number" successMessage="Phone number copied!" />
</div>
)}

Expand Down
77 changes: 77 additions & 0 deletions tests/unit/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { copyToClipboard } from '../../src/lib/utils/clipboard';
import { toast } from 'sonner';

vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
}
}));

describe('clipboard util', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('uses navigator.clipboard if available', async () => {
const mockWriteText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: mockWriteText },
configurable: true,
});
// mock window.isSecureContext
Object.defineProperty(window, 'isSecureContext', { value: true, writable: true });

const result = await copyToClipboard('test text');

expect(mockWriteText).toHaveBeenCalledWith('test text');
expect(result).toBe(true);
});

it('falls back to execCommand if navigator.clipboard is unavailable', async () => {
// hide navigator.clipboard
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
configurable: true,
});
Object.defineProperty(window, 'isSecureContext', { value: false, writable: true });

const mockExecCommand = vi.fn().mockReturnValue(true);
document.execCommand = mockExecCommand;

const result = await copyToClipboard('test text fallback');

expect(mockExecCommand).toHaveBeenCalledWith('copy');
expect(result).toBe(true);
});

it('handles permission denied error', async () => {
const mockWriteText = vi.fn().mockRejectedValue(new DOMException('Not allowed', 'NotAllowedError'));
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: mockWriteText },
configurable: true,
});
Object.defineProperty(window, 'isSecureContext', { value: true, writable: true });

const result = await copyToClipboard('test text');

expect(result).toBe(false);
expect(toast.error).toHaveBeenCalledWith('Clipboard permission denied');
});

it('handles generic fallback error', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
configurable: true,
});
Object.defineProperty(window, 'isSecureContext', { value: false, writable: true });
const mockExecCommand = vi.fn().mockReturnValue(false);
document.execCommand = mockExecCommand;

const result = await copyToClipboard('test text');

expect(result).toBe(false);
expect(toast.error).toHaveBeenCalledWith('Failed to copy to clipboard');
});
});