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
9 changes: 9 additions & 0 deletions scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ try {
// ------------------------------------------------------- contributor apply
await go('/issue/512');
await page.getByRole('heading', { name: 'Add a test helper for asserting emitted events' }).waitFor();
await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
}));
await page.getByRole('button', { name: 'Copy link' }).click();
const issueLink = page.getByRole('textbox', { name: 'Issue link; select and copy' });
assert.equal(await issueLink.inputValue(), `${base}/issue/512`);
assert.ok(await issueLink.evaluate(input => input.selectionStart === 0 && input.selectionEnd === input.value.length),
'the fallback selects the full issue URL for copying');
await page.getByRole('button', { name: 'Apply to this issue' }).click();
await page.waitForURL('**/login?**');
await page.getByLabel('Display name').fill('Casey Contributor');
Expand Down
41 changes: 38 additions & 3 deletions src/pages/issue.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { Link, Navigate, useNavigate, useParams } from 'react-router-dom';
import { ArrowUpRight, Check, GitPullRequest, Lock } from 'lucide-react';
import { ArrowUpRight, Check, Copy, GitPullRequest, Lock } from 'lucide-react';
import { applicantName, formatMoney, isOwnApplication, repoName, statusTone } from '../lib/model';
import { PLATFORM } from '../lib/platform';
import { useApp } from '../lib/store';
Expand All @@ -13,6 +13,8 @@ export function IssuePage() {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState('');
const [err, setErr] = useState('');
const [copyFallback, setCopyFallback] = useState(false);
const copyInput = useRef<HTMLInputElement>(null);

const issue = state.issues.find(i => i.id === issueId);
if (!issue) return <Navigate to="/explore" replace />;
Expand All @@ -25,6 +27,22 @@ export function IssuePage() {
const paid = taken?.status === 'Paid';
const canApply = !mine && !taken;

const copyLink = async () => {
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard API unavailable');
await navigator.clipboard.writeText(window.location.href);
setCopyFallback(false);
notify('Issue link copied.');
} catch {
setCopyFallback(true);
notify('Select the issue link below and copy it.');
window.setTimeout(() => {
copyInput.current?.focus();
copyInput.current?.select();
}, 0);
}
};

const submit = () => {
if (message.trim().length < 30) { setErr('Add at least 30 characters so the maintainer can judge your plan.'); return; }
setState(s => ({
Expand Down Expand Up @@ -60,7 +78,24 @@ export function IssuePage() {
<Chip>{issue.complexity}</Chip>
<Chip tone={paid ? '' : 'ok'}>{paid ? 'Paid out' : 'Escrowed'}</Chip>
</div>
<h1>{issue.title}</h1>
<div className="detail-title-row">
<h1>{issue.title}</h1>
<button className="btn ghost sm" type="button" onClick={copyLink}>
<Copy size={13} />Copy link
</button>
</div>
{copyFallback && (
<label className="copy-link-fallback">
<input
ref={copyInput}
className="input"
aria-label="Issue link; select and copy"
readOnly
value={window.location.href}
onFocus={event => event.currentTarget.select()}
/>
</label>
)}
<div className="row dim" style={{ gap: 7, fontSize: 'var(--t3)' }}>
<Avatar name={repo.org} org={repo.org} square />
<span>{repoName(repo)}</span>
Expand Down
4 changes: 4 additions & 0 deletions src/styles/pages.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ a.row-title:hover { text-decoration: underline; text-underline-offset: 2px; }
.detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--s7); align-items: start; }
.detail-head { padding-bottom: var(--s4); border-bottom: 1px solid var(--line); margin-bottom: var(--s5); }
.detail-head h1 { margin: var(--s3) 0 var(--s3); font-size: var(--t7); line-height: 1.25; }
.detail-title-row { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); flex-wrap: wrap; }
.detail-title-row h1 { min-width: 0; }
.copy-link-fallback { display: block; margin: 0 0 var(--s3); }
.copy-link-fallback .input { width: min(100%, 34rem); }
.prose h2 { font-size: var(--t5); margin-bottom: var(--s2); }
.prose h2 + p { font-size: var(--t4); color: var(--ink-2); }
.prose > * + h2 { margin-top: var(--s5); }
Expand Down